History

fix(mcp): harden project creation boundaries

Julius Marminge committed 2 months ago

Commit 7e7069a1 parent e395ca911 files · +176 −61Browse files

Unified · Split

11 files
  1. apps/server/src/mcp/ProjectMcpService.test.ts +58 −0
  2. apps/server/src/mcp/ProjectMcpService.ts +34 −45
  3. apps/server/src/mcp/toolkits/project/tools.ts +1 −1
  4. apps/server/src/mcp/toolkits/worktree/registration.test.ts +6 −0
  5. apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +1 −1
  6. apps/server/src/sourceControl/SourceControlRepositoryService.ts +1 −1
  7. packages/contracts/src/orchestration.ts +8 −6
  8. packages/contracts/src/project.test.ts +27 −0
  9. packages/contracts/src/project.ts +9 −3
  10. packages/contracts/src/projectMcp.test.ts +28 −1
  11. packages/contracts/src/projectMcp.ts +3 −3
apps/server/src/mcp/ProjectMcpService.test.ts+58−0
@@ -11,6 +11,7 @@ import { · 10 unmodified lines
11 ProviderInstanceId,11 ProviderInstanceId,
12 type ServerSettings,12 type ServerSettings,
13 ServerSettingsError,13 ServerSettingsError,
14 SourceControlRepositoryError,
14 ThreadId,15 ThreadId,
15} from "@t3tools/contracts";16} from "@t3tools/contracts";
16import * as Deferred from "effect/Deferred";17import * as Deferred from "effect/Deferred";
@@ -222,11 +223,68 @@ it.effect("loads response settings before committing project mutations", () => · 205 unmodified lines
222 })223 })
223 .pipe(Effect.flip);224 .pipe(Effect.flip);
224 expect(error.code).toBe("operation_failed");225 expect(error.code).toBe("operation_failed");
226 expect(error.message).toBe("Unable to read project defaults.");
227 expect(error.message).not.toMatch(/settings unavailable|settings\.json/);
225 expect(yield* Ref.get(createCalls)).toBe(0);228 expect(yield* Ref.get(createCalls)).toBe(0);
226 }).pipe(Effect.provide(testLayer));229 }).pipe(Effect.provide(testLayer));
227 }),230 }),
228);231);
229 232
233it.effect("redacts repository failures from public project MCP results", () =>
234 Effect.gen(function* () {
235 const secretRemote = "https://oauth-token@example.com/private/repo.git";
236 const testLayer = ProjectMcp.layer.pipe(
237 Layer.provide(
238 Layer.mock(ProjectService.ProjectService)({
239 getById: () => Effect.succeed(Option.none()),
240 }),
241 ),
242 Layer.provide(Layer.mock(T3ProjectFileLoader.T3ProjectFileLoader)({})),
243 Layer.provide(
244 Layer.mock(ServerSettingsService.ServerSettingsService)({
245 getSettings: Effect.succeed({ defaultThreadEnvMode: "local" } as ServerSettings),
246 }),
247 ),
248 Layer.provide(
249 Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({
250 cloneRepository: () =>
251 Effect.fail(
252 new SourceControlRepositoryError({
253 provider: "unknown",
254 operation: "cloneRepository",
255 detail: `fatal: authentication failed for ${secretRemote}`,
256 }),
257 ),
258 }),
259 ),
260 Layer.provide(Layer.mock(ThreadManagement.ThreadManagementService)({})),
261 Layer.provide(NodeServices.layer),
262 );
263
264 const error = yield* Effect.gen(function* () {
265 const service = yield* ProjectMcp.ProjectMcpService;
266 return yield* service
267 .create(scope, {
268 title: "Private clone",
269 source: {
270 type: "clone",
271 destinationPath: "/work/private-clone",
272 remoteUrl: secretRemote,
273 },
274 clientRequestId: "private-clone",
275 })
276 .pipe(Effect.flip);
277 }).pipe(Effect.provide(testLayer));
278
279 expect(error).toMatchObject({
280 code: "operation_failed",
281 message: "Unable to clone the requested repository.",
282 });
283 expect(error.message).not.toMatch(/oauth-token|example\.com|fatal|authentication/i);
284 expect(error).not.toHaveProperty("cause");
285 }),
286);
287
230it.effect("clones before registration and requires explicit cascading for nonempty projects", () =>288it.effect("clones before registration and requires explicit cascading for nonempty projects", () =>
231 Effect.gen(function* () {289 Effect.gen(function* () {
232 const projectId = ProjectId.make("project:mcp:provider-session-project-mcp:clone-and-delete");290 const projectId = ProjectId.make("project:mcp:provider-session-project-mcp:clone-and-delete");
apps/server/src/mcp/ProjectMcpService.ts+34−45
@@ -58,8 +58,14 @@ function failure(code: ProjectMcpFailure["code"], message: string): ProjectMcpFa · 57 unmodified lines
58 return new ProjectMcpFailure({ code, message });58 return new ProjectMcpFailure({ code, message });
59}59}
60 60
61function errorMessage(error: unknown): string {61function redactOperationFailure(operation: string, publicMessage: string) {
62 return error instanceof Error ? error.message : String(error);62 return <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, ProjectMcpFailure, R> =>
63 effect.pipe(
64 Effect.tapError((error) =>
65 Effect.logWarning("Project MCP operation failed.", { operation, error }),
66 ),
67 Effect.mapError(() => failure("operation_failed", publicMessage)),
68 );
63}69}
64 70
65function stablePart(value: string): string {71function stablePart(value: string): string {
@@ -113,9 +119,7 @@ const make = Effect.gen(function* () { · 47 unmodified lines
113 );119 );
114 120
115 const loadSettings = serverSettings.getSettings.pipe(121 const loadSettings = serverSettings.getSettings.pipe(
116 Effect.mapError((error) =>122 redactOperationFailure("read-server-settings", "Unable to read project defaults."),
117 failure("operation_failed", `Unable to read server settings: ${errorMessage(error)}`),
118 ),
119 );123 );
120 124
121 const projectWorkspaceDefaults = Effect.fn("ProjectMcpService.projectWorkspaceDefaults")(125 const projectWorkspaceDefaults = Effect.fn("ProjectMcpService.projectWorkspaceDefaults")(
@@ -171,14 +175,7 @@ const make = Effect.gen(function* () { · 49 unmodified lines
171 const loadProject = Effect.fn("ProjectMcpService.loadProject")(function* (projectId: ProjectId) {175 const loadProject = Effect.fn("ProjectMcpService.loadProject")(function* (projectId: ProjectId) {
172 const project = yield* projects176 const project = yield* projects
173 .getById(projectId)177 .getById(projectId)
174 .pipe(178 .pipe(redactOperationFailure("read-project", "Unable to read the requested project."));
175 Effect.mapError((error) =>
176 failure(
177 "operation_failed",
178 `Unable to read project ${projectId}: ${errorMessage(error)}`,
179 ),
180 ),
181 );
182 if (Option.isNone(project)) {179 if (Option.isNone(project)) {
183 return yield* failure("project_not_found", `Project '${projectId}' was not found.`);180 return yield* failure("project_not_found", `Project '${projectId}' was not found.`);
184 }181 }
@@ -189,9 +186,7 @@ const make = Effect.gen(function* () { · 4 unmodified lines
189 Effect.gen(function* () {186 Effect.gen(function* () {
190 yield* requireCapability(scope);187 yield* requireCapability(scope);
191 const snapshot = yield* projects.snapshot.pipe(188 const snapshot = yield* projects.snapshot.pipe(
192 Effect.mapError((error) =>189 redactOperationFailure("list-projects", "Unable to list projects."),
193 failure("operation_failed", `Unable to list projects: ${errorMessage(error)}`),
194 ),
195 );190 );
196 const settings = yield* loadSettings;191 const settings = yield* loadSettings;
197 const cursor = input.cursor ?? 0;192 const cursor = input.cursor ?? 0;
@@ -230,11 +225,9 @@ const make = Effect.gen(function* () { · 32 unmodified lines
230 const existing = yield* projects225 const existing = yield* projects
231 .getById(projectId, { includeDeleted: true })226 .getById(projectId, { includeDeleted: true })
232 .pipe(227 .pipe(
233 Effect.mapError((error) =>228 redactOperationFailure(
234 failure(229 "read-project-create-receipt",
235 "operation_failed",230 "Unable to check the project creation result.",
236 `Unable to retry project creation: ${errorMessage(error)}`,
237 ),
238 ),231 ),
239 );232 );
240 if (Option.isSome(existing)) {233 if (Option.isSome(existing)) {
@@ -276,8 +269,9 @@ const make = Effect.gen(function* () { · 35 unmodified lines
276 ...(input.source.protocol === undefined ? {} : { protocol: input.source.protocol }),269 ...(input.source.protocol === undefined ? {} : { protocol: input.source.protocol }),
277 })270 })
278 .pipe(271 .pipe(
279 Effect.mapError((error) =>272 redactOperationFailure(
280 failure("operation_failed", `Unable to clone project: ${errorMessage(error)}`),273 "clone-project-repository",
274 "Unable to clone the requested repository.",
281 ),275 ),
282 );276 );
283 workspaceRoot = cloned.cwd;277 workspaceRoot = cloned.cwd;
@@ -300,9 +294,7 @@ const make = Effect.gen(function* () { · 16 unmodified lines
300 ...(input.scripts === undefined ? {} : { scripts: input.scripts }),294 ...(input.scripts === undefined ? {} : { scripts: input.scripts }),
301 })295 })
302 .pipe(296 .pipe(
303 Effect.mapError((error) =>297 redactOperationFailure("create-project", "Unable to create the requested project."),
304 failure("operation_failed", `Unable to create project: ${errorMessage(error)}`),
305 ),
306 );298 );
307 return yield* projectView(project, settings);299 return yield* projectView(project, settings);
308 }),300 }),
@@ -345,10 +337,16 @@ const make = Effect.gen(function* () { · 36 unmodified lines
345 ...(input.scripts === undefined ? {} : { scripts: input.scripts }),337 ...(input.scripts === undefined ? {} : { scripts: input.scripts }),
346 })338 })
347 .pipe(339 .pipe(
340 Effect.tapError((error) =>
341 Effect.logWarning("Project MCP operation failed.", {
342 operation: "update-project",
343 error,
344 }),
345 ),
348 Effect.mapError((error) =>346 Effect.mapError((error) =>
349 error._tag === "ProjectNotFoundError"347 error._tag === "ProjectNotFoundError"
350 ? failure("project_not_found", error.message)348 ? failure("project_not_found", `Project '${input.projectId}' was not found.`)
351 : failure("operation_failed", `Unable to update project: ${errorMessage(error)}`),349 : failure("operation_failed", "Unable to update the requested project."),
352 ),350 ),
353 );351 );
354 return yield* projectView(project, settings);352 return yield* projectView(project, settings);
@@ -359,14 +357,7 @@ const make = Effect.gen(function* () { · 4 unmodified lines
359 yield* requireCapability(scope);357 yield* requireCapability(scope);
360 const projectOption = yield* projects358 const projectOption = yield* projects
361 .getById(input.projectId, { includeDeleted: true })359 .getById(input.projectId, { includeDeleted: true })
362 .pipe(360 .pipe(redactOperationFailure("read-project-for-delete", "Unable to read the project."));
363 Effect.mapError((error) =>
364 failure(
365 "operation_failed",
366 `Unable to read project ${input.projectId}: ${errorMessage(error)}`,
367 ),
368 ),
369 );
370 if (Option.isNone(projectOption)) {361 if (Option.isNone(projectOption)) {
371 return yield* failure("project_not_found", `Project '${input.projectId}' was not found.`);362 return yield* failure("project_not_found", `Project '${input.projectId}' was not found.`);
372 }363 }
@@ -385,8 +376,9 @@ const make = Effect.gen(function* () { · 12 unmodified lines
385 threads.getShellSnapshot({ location: "active" }),376 threads.getShellSnapshot({ location: "active" }),
386 threads.getShellSnapshot({ location: "archive" }),377 threads.getShellSnapshot({ location: "archive" }),
387 ]).pipe(378 ]).pipe(
388 Effect.mapError((error) =>379 redactOperationFailure(
389 failure("operation_failed", `Unable to inspect project threads: ${errorMessage(error)}`),380 "inspect-project-threads",
381 "Unable to inspect the project's threads.",
390 ),382 ),
391 );383 );
392 const projectThreads = [384 const projectThreads = [
@@ -423,8 +415,9 @@ const make = Effect.gen(function* () { · 30 unmodified lines
423 }),415 }),
424 { concurrency: 1, discard: true },416 { concurrency: 1, discard: true },
425 ).pipe(417 ).pipe(
426 Effect.mapError((error) =>418 redactOperationFailure(
427 failure("operation_failed", `Unable to delete project threads: ${errorMessage(error)}`),419 "delete-project-threads",
420 "Unable to delete the project's thread records.",
428 ),421 ),
429 );422 );
430 yield* projects423 yield* projects
@@ -437,11 +430,7 @@ const make = Effect.gen(function* () { · 6 unmodified lines
437 }),430 }),
438 projectId: input.projectId,431 projectId: input.projectId,
439 })432 })
440 .pipe(433 .pipe(redactOperationFailure("delete-project", "Unable to delete the requested project."));
441 Effect.mapError((error) =>
442 failure("operation_failed", `Unable to delete project: ${errorMessage(error)}`),
443 ),
444 );
445 return {434 return {
446 projectId: input.projectId,435 projectId: input.projectId,
447 deleted: true,436 deleted: true,
apps/server/src/mcp/toolkits/project/tools.ts+1−1
@@ -58,7 +58,7 @@ export const ProjectCreateTool = Tool.make("t3_project_create", { · 57 unmodified lines
58 .annotate(Tool.Title, "Create a T3 project")58 .annotate(Tool.Title, "Create a T3 project")
59 .annotate(Tool.Readonly, false)59 .annotate(Tool.Readonly, false)
60 .annotate(Tool.Destructive, false)60 .annotate(Tool.Destructive, false)
61 .annotate(Tool.Idempotent, true)61 .annotate(Tool.Idempotent, false)
62 .annotate(Tool.OpenWorld, true);62 .annotate(Tool.OpenWorld, true);
63 63
64export const ProjectUpdateTool = Tool.make("t3_project_update", {64export const ProjectUpdateTool = Tool.make("t3_project_update", {
apps/server/src/mcp/toolkits/worktree/registration.test.ts+6−0
@@ -118,6 +118,12 @@ it.effect("production mcp layer lists worktree tools over http", () => · 117 unmodified lines
118 expect(toolNames).toContain("delegate_task");118 expect(toolNames).toContain("delegate_task");
119 expect(toolNames).toContain("t3_project_list");119 expect(toolNames).toContain("t3_project_list");
120 expect(toolNames).toContain("t3_project_delete");120 expect(toolNames).toContain("t3_project_delete");
121 const projectCreate = tools.find((tool) => tool.name === "t3_project_create");
122 expect(
123 projectCreate?.annotations && "idempotentHint" in projectCreate.annotations
124 ? projectCreate.annotations.idempotentHint
125 : undefined,
126 ).not.toBe(true);
121 127
122 // The handoff tool mutates thread state, reaches the network (origin128 // The handoff tool mutates thread state, reaches the network (origin
123 // fetch), and runs project setup scripts, so its MCP hints must not129 // fetch), and runs project setup scripts, so its MCP hints must not
apps/server/src/sourceControl/SourceControlRepositoryService.test.ts+1−1
@@ -209,7 +209,7 @@ it.effect("clones a looked-up repository into the requested destination", () => · 208 unmodified lines
209 assert.deepStrictEqual(cloneCalls, [209 assert.deepStrictEqual(cloneCalls, [
210 {210 {
211 cwd: `${parent}/missing/nested`,211 cwd: `${parent}/missing/nested`,
212 args: ["clone", CLONE_URLS.url, "t3code"],212 args: ["clone", "--origin", "origin", CLONE_URLS.url, "t3code"],
213 },213 },
214 ]);214 ]);
215 }).pipe(215 }).pipe(
apps/server/src/sourceControl/SourceControlRepositoryService.ts+1−1
@@ -310,7 +310,7 @@ export const make = Effect.gen(function* () { · 309 unmodified lines
310 yield* git.execute({310 yield* git.execute({
311 operation: "SourceControlRepositoryService.cloneRepository",311 operation: "SourceControlRepositoryService.cloneRepository",
312 cwd: preparedDestination.parentPath,312 cwd: preparedDestination.parentPath,
313 args: ["clone", remoteUrl, preparedDestination.directoryName],313 args: ["clone", "--origin", "origin", remoteUrl, preparedDestination.directoryName],
314 timeoutMs: 120_000,314 timeoutMs: 120_000,
315 maxOutputBytes: 256 * 1024,315 maxOutputBytes: 256 * 1024,
316 });316 });
packages/contracts/src/orchestration.ts+8−6
@@ -26,7 +26,13 @@ import { · 25 unmodified lines
26 RuntimeMode,26 RuntimeMode,
27} from "./providerPolicy.ts";27} from "./providerPolicy.ts";
28import { ProviderInstanceId } from "./providerInstance.ts";28import { ProviderInstanceId } from "./providerInstance.ts";
29import { ProjectScript, ProjectIconOverride } from "./project.ts";29import {
30 Project,
31 ProjectFaviconPath,
32 ProjectIconOverride,
33 ProjectScript,
34 ProjectScriptIcon,
35} from "./project.ts";
30export { ProjectIconColor, ProjectIconOverride } from "./project.ts";36export { ProjectIconColor, ProjectIconOverride } from "./project.ts";
31import { RepositoryIdentity, ThreadEnvMode } from "./environment.ts";37import { RepositoryIdentity, ThreadEnvMode } from "./environment.ts";
32import { OrchestrationProjectShell } from "./orchestrationProject.ts";38import { OrchestrationProjectShell } from "./orchestrationProject.ts";
@@ -61,11 +67,7 @@ export { · 28 unmodified lines
61export const CorrelationId = CommandId;67export const CorrelationId = CommandId;
62export type CorrelationId = typeof CorrelationId.Type;68export type CorrelationId = typeof CorrelationId.Type;
63 69
64export const ProjectFaviconPath = TrimmedNonEmptyString.check(70export { ProjectFaviconPath } from "./project.ts";
65 Schema.isMaxLength(1024),
66 Schema.isPattern(/\.(?:avif|gif|ico|jpe?g|png|svg|webp)$/i),
67);
68export type ProjectFaviconPath = typeof ProjectFaviconPath.Type;
69 71
70export const OrchestrationProject = Schema.Struct({72export const OrchestrationProject = Schema.Struct({
71 id: ProjectId,73 id: ProjectId,
packages/contracts/src/project.test.ts+27−0
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; · 2 unmodified lines
3 3
4import {4import {
5 ProjectReadFileError,5 ProjectReadFileError,
6 ProjectMutation,
6 ProjectSearchContentsError,7 ProjectSearchContentsError,
7 ProjectSearchContentsInput,8 ProjectSearchContentsInput,
8 ProjectSearchEntriesError,9 ProjectSearchEntriesError,
@@ -12,6 +13,7 @@ import { · 3 unmodified lines
12 13
13const decodeSearchEntriesInput = Schema.decodeUnknownSync(ProjectSearchEntriesInput);14const decodeSearchEntriesInput = Schema.decodeUnknownSync(ProjectSearchEntriesInput);
14const decodeSearchContentsInput = Schema.decodeUnknownSync(ProjectSearchContentsInput);15const decodeSearchContentsInput = Schema.decodeUnknownSync(ProjectSearchContentsInput);
16const decodeProjectMutation = Schema.decodeUnknownSync(ProjectMutation);
15 17
16describe("project search inputs", () => {18describe("project search inputs", () => {
17 it("allows an empty entries query for bounded frecency browsing", () => {19 it("allows an empty entries query for bounded frecency browsing", () => {
@@ -37,6 +39,31 @@ describe("project search inputs", () => { · 19 unmodified lines
37 });39 });
38});40});
39 41
42describe("project mutations", () => {
43 it("rejects favicon paths the asset endpoint cannot serve", () => {
44 const created = decodeProjectMutation({
45 type: "project.create",
46 commandId: "command-project-create",
47 projectId: "project-1",
48 title: "Project",
49 workspaceRoot: "/work/project",
50 faviconPath: "/work/project/favicon.png",
51 });
52 expect(created).toMatchObject({
53 type: "project.create",
54 faviconPath: "/work/project/favicon.png",
55 });
56 expect(() =>
57 decodeProjectMutation({
58 type: "project.update",
59 commandId: "command-project-update",
60 projectId: "project-1",
61 faviconPath: "/work/project/.env",
62 }),
63 ).toThrow();
64 });
65});
66
40describe("project RPC errors", () => {67describe("project RPC errors", () => {
41 it("derives stable messages from structured request context while retaining causes", () => {68 it("derives stable messages from structured request context while retaining causes", () => {
42 const cause = new Error("sensitive platform detail");69 const cause = new Error("sensitive platform detail");
packages/contracts/src/project.ts+9−3
@@ -16,6 +16,12 @@ const PROJECT_SEARCH_CONTENTS_MAX_LIMIT = 500; · 15 unmodified lines
16const PROJECT_WRITE_FILE_PATH_MAX_LENGTH = 512;16const PROJECT_WRITE_FILE_PATH_MAX_LENGTH = 512;
17const PROJECT_READ_FILE_PATH_MAX_LENGTH = 512;17const PROJECT_READ_FILE_PATH_MAX_LENGTH = 512;
18 18
19export const ProjectFaviconPath = TrimmedNonEmptyString.check(
20 Schema.isMaxLength(1024),
21 Schema.isPattern(/\.(?:avif|gif|ico|jpe?g|png|svg|webp)$/i),
22);
23export type ProjectFaviconPath = typeof ProjectFaviconPath.Type;
24
19export const ProjectScriptIcon = Schema.Literals([25export const ProjectScriptIcon = Schema.Literals([
20 "play",26 "play",
21 "test",27 "test",
@@ -84,7 +90,7 @@ export const Project = Schema.Struct({ · 62 unmodified lines
84 title: TrimmedNonEmptyString,90 title: TrimmedNonEmptyString,
85 workspaceRoot: TrimmedNonEmptyString,91 workspaceRoot: TrimmedNonEmptyString,
86 repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)),92 repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)),
87 faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),93 faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)),
88 projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)),94 projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)),
89 defaultModelSelection: Schema.NullOr(ModelSelection),95 defaultModelSelection: Schema.NullOr(ModelSelection),
90 defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)),96 defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)),
@@ -123,7 +129,7 @@ export const ProjectMutation = Schema.Union([ · 32 unmodified lines
123 createWorkspaceRootIfMissing: Schema.optional(Schema.Boolean),129 createWorkspaceRootIfMissing: Schema.optional(Schema.Boolean),
124 defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)),130 defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)),
125 defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)),131 defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)),
126 faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),132 faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)),
127 scripts: Schema.optional(Schema.Array(ProjectScript)),133 scripts: Schema.optional(Schema.Array(ProjectScript)),
128 }),134 }),
129 Schema.Struct({135 Schema.Struct({
@@ -135,7 +141,7 @@ export const ProjectMutation = Schema.Union([ · 5 unmodified lines
135 defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)),141 defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)),
136 autoPull: Schema.optional(Schema.Boolean),142 autoPull: Schema.optional(Schema.Boolean),
137 projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)),143 projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)),
138 faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),144 faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)),
139 defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)),145 defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)),
140 scripts: Schema.optional(Schema.Array(ProjectScript)),146 scripts: Schema.optional(Schema.Array(ProjectScript)),
141 }),147 }),
packages/contracts/src/projectMcp.test.ts+28−1
@@ -1,10 +1,16 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import * as Schema from "effect/Schema";2import * as Schema from "effect/Schema";
3 3
4import { ProjectMcpCreateInput, ProjectMcpFailure, ProjectMcpListInput } from "./projectMcp.ts";4import {
5 ProjectMcpCreateInput,
6 ProjectMcpFailure,
7 ProjectMcpListInput,
8 ProjectMcpUpdateInput,
9} from "./projectMcp.ts";
5 10
6const decodeCreateInput = Schema.decodeUnknownSync(ProjectMcpCreateInput);11const decodeCreateInput = Schema.decodeUnknownSync(ProjectMcpCreateInput);
7const decodeListInput = Schema.decodeUnknownSync(ProjectMcpListInput);12const decodeListInput = Schema.decodeUnknownSync(ProjectMcpListInput);
13const decodeUpdateInput = Schema.decodeUnknownSync(ProjectMcpUpdateInput);
8const decodeFailure = Schema.decodeUnknownSync(ProjectMcpFailure);14const decodeFailure = Schema.decodeUnknownSync(ProjectMcpFailure);
9 15
10describe("project MCP contracts", () => {16describe("project MCP contracts", () => {
@@ -71,4 +77,25 @@ describe("project MCP contracts", () => { · 60 unmodified lines
71 }),77 }),
72 ).toMatchObject({ code: "project_deleted" });78 ).toMatchObject({ code: "project_deleted" });
73 });79 });
80
81 it("accepts only favicon paths served by the project asset route", () => {
82 expect(
83 decodeCreateInput({
84 title: "Icon project",
85 source: { type: "existing_directory", workspaceRoot: "/work/icon" },
86 faviconPath: "/work/icon/favicon.svg",
87 }).faviconPath,
88 ).toBe("/work/icon/favicon.svg");
89 expect(() =>
90 decodeCreateInput({
91 title: "Secret project",
92 source: { type: "existing_directory", workspaceRoot: "/work/secret" },
93 faviconPath: "/work/secret/.env",
94 }),
95 ).toThrow();
96 expect(decodeUpdateInput({ projectId: "project-1", faviconPath: null }).faviconPath).toBeNull();
97 expect(() =>
98 decodeUpdateInput({ projectId: "project-1", faviconPath: "/work/secret/.env" }),
99 ).toThrow();
100 });
74});101});
packages/contracts/src/projectMcp.ts+3−3
@@ -3,7 +3,7 @@ import * as Schema from "effect/Schema"; · 2 unmodified lines
3import { NonNegativeInt, PositiveInt, ProjectId, TrimmedNonEmptyString } from "./baseSchemas.ts";3import { NonNegativeInt, PositiveInt, ProjectId, TrimmedNonEmptyString } from "./baseSchemas.ts";
4import { ThreadEnvMode } from "./environment.ts";4import { ThreadEnvMode } from "./environment.ts";
5import { ModelSelection } from "./modelSelection.ts";5import { ModelSelection } from "./modelSelection.ts";
6import { Project, ProjectScript } from "./project.ts";6import { Project, ProjectFaviconPath, ProjectScript } from "./project.ts";
7import { SourceControlCloneProtocol, SourceControlProviderKind } from "./sourceControl.ts";7import { SourceControlCloneProtocol, SourceControlProviderKind } from "./sourceControl.ts";
8 8
9const ProjectMcpClientRequestId = TrimmedNonEmptyString.check(Schema.isMaxLength(256)).annotate({9const ProjectMcpClientRequestId = TrimmedNonEmptyString.check(Schema.isMaxLength(256)).annotate({
@@ -91,7 +91,7 @@ export const ProjectMcpCreateInput = Schema.Struct({ · 81 unmodified lines
91 source: ProjectMcpCreateSource,91 source: ProjectMcpCreateSource,
92 defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)),92 defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)),
93 defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)),93 defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)),
94 faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),94 faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)),
95 scripts: Schema.optional(Schema.Array(ProjectScript)),95 scripts: Schema.optional(Schema.Array(ProjectScript)),
96 clientRequestId: Schema.optional(ProjectMcpClientRequestId),96 clientRequestId: Schema.optional(ProjectMcpClientRequestId),
97});97});
@@ -103,7 +103,7 @@ export const ProjectMcpUpdateInput = Schema.Struct({ · 5 unmodified lines
103 workspaceRoot: Schema.optional(TrimmedNonEmptyString),103 workspaceRoot: Schema.optional(TrimmedNonEmptyString),
104 defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)),104 defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)),
105 defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)),105 defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)),
106 faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),106 faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)),
107 scripts: Schema.optional(Schema.Array(ProjectScript)),107 scripts: Schema.optional(Schema.Array(ProjectScript)),
108 clientRequestId: Schema.optional(ProjectMcpClientRequestId),108 clientRequestId: Schema.optional(ProjectMcpClientRequestId),
109});109});

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t