diff --git a/.gitignore b/.gitignore index a14702c..96fdc49 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,5 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json # Finder (MacOS) folder config .DS_Store + +**/.claude/settings.local.json diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f3543ec --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,35 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +# Shortcut MCP Server Development Guide + +## Commands +- **Build**: `bun run build` or `npm run build` +- **Lint/Format**: `bun run lint` / `bun run format` (uses Biome) +- **Type check**: `bun run ts` +- **Run all tests**: `bun test` or `bun run test` +- **Run single test**: `bun test -t "test name"` or `bun test path/to/specific.test.ts` + +## Code Style Guidelines +- **Formatting**: + - Tab indentation, 100 character line width, double quotes + - Organized imports (by type/path using Biome) +- **TypeScript**: + - Use strict typing with proper annotations + - Leverage Zod for schema validation + - Follow TypeScript best practices for null checking +- **Naming**: + - PascalCase for classes + - camelCase for variables, functions, and methods + - Descriptive function names + +## Error Handling +- Use explicit error throwing with descriptive messages +- Follow proper async/await patterns +- Perform null checking before operations + +## Testing +- Test files co-located with implementation files (*.test.ts) +- Use descriptive test organization with `describe` and `test` blocks +- Implement mock functions and spies as needed \ No newline at end of file diff --git a/PROJECT_STRUCTURE.md b/PROJECT_STRUCTURE.md new file mode 100644 index 0000000..eaf1ff1 --- /dev/null +++ b/PROJECT_STRUCTURE.md @@ -0,0 +1,122 @@ +# Shortcut MCP Project Structure + +This document provides an overview of the project structure for the Shortcut MCP implementation. + +## Project Components + +The project is now organized into three main components: + +1. **`shortcut-mcp-tools`**: A shared package containing all Shortcut-specific tools and client logic +2. **`mcp-server-shortcut`**: The original stdio-based MCP server (CLI tool) +3. **`shortcut-mcp-worker`**: A Cloudflare Worker implementation for remote MCP access + +## Shared Tools Package + +### Location +`/shortcut-mcp-tools/` + +### Description +This package contains all the Shortcut-specific tools and client logic that is shared between both the stdio-based and Cloudflare Worker implementations. This eliminates code duplication and ensures consistent behavior across different transports. + +### Key Files +- `src/client/shortcut.ts`: Wrapper around the Shortcut API client +- `src/client/cache.ts`: Simple caching implementation for API responses +- `src/tools/*.ts`: Various tools for interacting with Shortcut (stories, epics, etc.) +- `src/tools/utils/*.ts`: Utility functions for formatting, validation, etc. +- `src/index.ts`: Exports all tools and utilities + +## Original MCP Server (stdio-based) + +### Location +`/` (root directory) + +### Description +The original implementation of the Shortcut MCP server that uses stdio for communication. This is designed to be run as a CLI tool and is compatible with Claude Code, Cursor, and Windsurf. + +### Key Files +- `index.ts`: Entry point for the CLI tool +- `src/server.ts`: MCP server setup with stdio transport +- Other configuration files (biome.json, tsconfig.json, etc.) + +### Dependencies +- Now depends on the shared tools package +- Uses stdio transport from the MCP SDK + +## Cloudflare Worker Implementation + +### Location +`/shortcut-mcp-worker/` + +### Description +A new implementation of the Shortcut MCP server that runs on Cloudflare Workers. This allows for remote access to the MCP server without needing to run the CLI tool locally. + +### Key Files +- `src/index.ts`: Main entry point for the Worker +- `src/transport/http-sse.ts`: HTTP+SSE transport implementation for MCP +- `src/auth.ts`: OAuth authentication implementation +- `wrangler.toml`: Cloudflare Worker configuration + +### Dependencies +- Depends on the shared tools package +- Uses HTTP+SSE transport instead of stdio +- Adds OAuth authentication via Cloudflare Workers OAuth provider + +## How to Use + +### Local Development + +1. **Install dependencies for all components**: + ```bash + # Install dependencies for the shared package + cd shortcut-mcp-tools + npm install + + # Install dependencies for the original server + cd .. + npm install + + # Install dependencies for the worker + cd shortcut-mcp-worker + npm install + ``` + +2. **Build the shared package**: + ```bash + cd shortcut-mcp-tools + npm run build + ``` + +3. **Run the original server**: + ```bash + cd .. + SHORTCUT_API_TOKEN=your-token npm run build + node dist/index.js + ``` + +4. **Run the worker locally**: + ```bash + cd shortcut-mcp-worker + npm run dev + ``` + +### Deployment + +1. **Publish the shared package** (if needed): + ```bash + cd shortcut-mcp-tools + npm publish + ``` + +2. **Deploy the worker**: + ```bash + cd shortcut-mcp-worker + npm run deploy + ``` + +## Architecture Benefits + +1. **Code Sharing**: All business logic is in one place +2. **Consistent Behavior**: Both implementations use the same tools +3. **Easier Maintenance**: Changes to tools only need to be made in one place +4. **Flexible Deployment**: Can be used locally or remotely +5. **Progressive Enhancement**: New tools can be added to the shared package and used by both implementations \ No newline at end of file diff --git a/package.json b/package.json index bbc3504..60d4f0a 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.6.1", "@shortcut/client": "^1.1.0", + "@shortcut/mcp-tools": "file:./shortcut-mcp-tools", "zod": "^3.24.2" }, "scripts": { diff --git a/shortcut-mcp-tools/README.md b/shortcut-mcp-tools/README.md new file mode 100644 index 0000000..5656832 --- /dev/null +++ b/shortcut-mcp-tools/README.md @@ -0,0 +1,57 @@ +# Shortcut MCP Tools + +Shared tools library for Shortcut MCP implementations. This package provides a consistent set of tools for interacting with the Shortcut API, regardless of the transport mechanism used. + +## Installation + +```bash +npm install @shortcut/mcp-tools +``` + +## Usage + +### Initialize the client + +```typescript +import { ShortcutClient } from '@shortcut/client'; +import { ShortcutClientWrapper, StoryTools } from '@shortcut/mcp-tools'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; + +// Initialize the client with your API token +const client = new ShortcutClientWrapper(new ShortcutClient('your-api-token')); + +// Initialize the MCP server +const server = new McpServer({ name: 'your-mcp-server', version: '1.0.0' }); + +// Register tools +StoryTools.create(client, server); +// Register other tools as needed... +``` + +## Available Tools + +- `UserTools`: Get information about the current user. +- `StoryTools`: Work with Shortcut stories (search, create, update, comment). +- `EpicTools`: Work with Shortcut epics. +- `IterationTools`: Work with Shortcut iterations. +- `ObjectiveTools`: Work with Shortcut objectives. +- `TeamTools`: Work with Shortcut teams. +- `WorkflowTools`: Work with Shortcut workflows. + +## Development + +### Building + +```bash +npm run build +``` + +### Publishing + +```bash +npm publish +``` + +## License + +MIT \ No newline at end of file diff --git a/shortcut-mcp-tools/package.json b/shortcut-mcp-tools/package.json new file mode 100644 index 0000000..dc2147a --- /dev/null +++ b/shortcut-mcp-tools/package.json @@ -0,0 +1,34 @@ +{ + "name": "@shortcut/mcp-tools", + "version": "0.1.0", + "description": "Shared tools for Shortcut MCP implementations", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -b", + "clean": "rm -rf dist", + "prepublishOnly": "npm run clean && npm run build" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.6.1", + "@shortcut/client": "^1.1.0", + "zod": "^3.24.2" + }, + "devDependencies": { + "@modelcontextprotocol/sdk": "^1.6.1", + "@shortcut/client": "^1.1.0", + "typescript": "^5.0.0", + "zod": "^3.24.2" + }, + "keywords": [ + "shortcut", + "mcp", + "modelcontextprotocol" + ], + "author": "Shortcut (https://www.shortcut.com)", + "license": "MIT" +} \ No newline at end of file diff --git a/shortcut-mcp-tools/src/client/cache.ts b/shortcut-mcp-tools/src/client/cache.ts new file mode 100644 index 0000000..8c12423 --- /dev/null +++ b/shortcut-mcp-tools/src/client/cache.ts @@ -0,0 +1,40 @@ +/** + * Simple key/value cache. + * + * It only supports setting **all** values at once. You cannot add to the cache over time. + * It tracks staleness and is hard coded to expire after 5 minutes. + */ +export class Cache { + private cache: Map; + private age: number; + + constructor() { + this.cache = new Map(); + this.age = 0; + } + + get(key: TId) { + return this.cache.get(key) ?? null; + } + + values() { + return Array.from(this.cache.values()); + } + + setMany(values: [TId, TValue][]) { + this.cache.clear(); + for (const [key, value] of values) { + this.cache.set(key, value); + } + this.age = Date.now(); + } + + clear() { + this.cache.clear(); + this.age = 0; + } + + get isStale() { + return Date.now() - this.age > 1000 * 60 * 5; + } +} diff --git a/shortcut-mcp-tools/src/client/shortcut.ts b/shortcut-mcp-tools/src/client/shortcut.ts new file mode 100644 index 0000000..8d227a0 --- /dev/null +++ b/shortcut-mcp-tools/src/client/shortcut.ts @@ -0,0 +1,267 @@ +import type { + ShortcutClient as BaseClient, + CreateEpic, + CreateIteration, + CreateStoryComment, + CreateStoryParams, + Epic, + Iteration, + Member, + MemberInfo, + StoryComment, + UpdateStory, + Workflow, +} from "@shortcut/client"; +import { Cache } from "./cache"; + +/** + * This is a thin wrapper over the official Shortcut API client. + * + * Its main reasons for existing are: + * - Add a caching layer for common calls like fetching members or teams. + * - Unwrap and simplify some response types. + * - Only expose a subset of methods and a subset of the possible input parameters to those methods. + */ +export class ShortcutClientWrapper { + private currentUser: MemberInfo | null = null; + private userCache: Cache; + private workflowCache: Cache; + + constructor(private client: BaseClient) { + this.userCache = new Cache(); + this.workflowCache = new Cache(); + } + + private async loadMembers() { + if (this.userCache.isStale) { + const response = await this.client.listMembers({}); + const members = response?.data ?? null; + + if (members) { + this.userCache.setMany(members.map((member) => [member.id, member])); + } + } + } + + private async loadWorkflows() { + if (this.workflowCache.isStale) { + const response = await this.client.listWorkflows(); + const workflows = response?.data ?? null; + + if (workflows) { + this.workflowCache.setMany(workflows.map((workflow) => [workflow.id, workflow])); + } + } + } + + async getCurrentUser() { + if (this.currentUser) return this.currentUser; + + const response = await this.client.getCurrentMemberInfo(); + const user = response?.data; + + if (!user) return null; + + this.currentUser = user; + + return user; + } + + async getUser(userId: string) { + const response = await this.client.getMember(userId, {}); + const user = response?.data; + + if (!user) return null; + + return user; + } + + async getUserMap(userIds: string[]) { + await this.loadMembers(); + return new Map( + userIds + .map((id) => [id, this.userCache.get(id)]) + .filter((user): user is [string, Member] => user[1] !== null), + ); + } + + async getUsers(userIds: string[]) { + await this.loadMembers(); + return userIds + .map((id) => this.userCache.get(id)) + .filter((user): user is Member => user !== null); + } + + async getWorkflowMap(workflowIds: number[]) { + await this.loadWorkflows(); + return new Map( + workflowIds + .map((id) => [id, this.workflowCache.get(id)]) + .filter((workflow): workflow is [number, Workflow] => workflow[1] !== null), + ); + } + + async getWorkflows() { + await this.loadWorkflows(); + return Array.from(this.workflowCache.values()); + } + + async getWorkflow(workflowPublicId: number) { + const response = await this.client.getWorkflow(workflowPublicId); + const workflow = response?.data; + + if (!workflow) return null; + + return workflow; + } + + async getTeams() { + const response = await this.client.listGroups(); + const groups = response?.data ?? []; + return groups; + } + + async getTeam(teamPublicId: string) { + const response = await this.client.getGroup(teamPublicId); + const group = response?.data; + + if (!group) return null; + + return group; + } + + async createStory(params: CreateStoryParams) { + const response = await this.client.createStory(params); + const story = response?.data ?? null; + + if (!story) throw new Error(`Failed to create the story: ${response.status}`); + + return story; + } + + async updateStory(storyPublicId: number, params: UpdateStory) { + const response = await this.client.updateStory(storyPublicId, params); + const story = response?.data ?? null; + + if (!story) throw new Error(`Failed to update the story: ${response.status}`); + + return story; + } + + async getStory(storyPublicId: number) { + const response = await this.client.getStory(storyPublicId); + const story = response?.data ?? null; + + if (!story) return null; + + return story; + } + + async getEpic(epicPublicId: number) { + const response = await this.client.getEpic(epicPublicId); + const epic = response?.data ?? null; + + if (!epic) return null; + + return epic; + } + + async getIteration(iterationPublicId: number) { + const response = await this.client.getIteration(iterationPublicId); + const iteration = response?.data ?? null; + + if (!iteration) return null; + + return iteration; + } + + async getMilestone(milestonePublicId: number) { + const response = await this.client.getMilestone(milestonePublicId); + const milestone = response?.data ?? null; + + if (!milestone) return null; + + return milestone; + } + + async searchStories(query: string) { + const response = await this.client.searchStories({ query, page_size: 25, detail: "slim" }); + const stories = response?.data?.data; + const total = response?.data?.total; + + if (!stories) return { stories: null, total: null }; + + return { stories, total }; + } + + async searchIterations(query: string) { + const response = await this.client.searchIterations({ query, page_size: 25, detail: "slim" }); + const iterations = response?.data?.data; + const total = response?.data?.total; + + if (!iterations) return { iterations: null, total: null }; + + return { iterations, total }; + } + + async searchEpics(query: string) { + const response = await this.client.searchEpics({ query, page_size: 25, detail: "slim" }); + const epics = response?.data?.data; + const total = response?.data?.total; + + if (!epics) return { epics: null, total: null }; + + return { epics, total }; + } + + async searchMilestones(query: string) { + const response = await this.client.searchMilestones({ query, page_size: 25, detail: "slim" }); + const milestones = response?.data?.data; + const total = response?.data?.total; + + if (!milestones) return { milestones: null, total: null }; + + return { milestones, total }; + } + + async listIterationStories(iterationPublicId: number) { + const response = await this.client.listIterationStories(iterationPublicId, { + includes_description: false, + }); + const stories = response?.data; + + if (!stories) return { stories: null, total: null }; + + return { stories, total: stories.length }; + } + + async createStoryComment( + storyPublicId: number, + params: CreateStoryComment, + ): Promise { + const response = await this.client.createStoryComment(storyPublicId, params); + const storyComment = response?.data ?? null; + + if (!storyComment) throw new Error(`Failed to create the comment: ${response.status}`); + + return storyComment; + } + + async createIteration(params: CreateIteration): Promise { + const response = await this.client.createIteration(params); + const iteration = response?.data ?? null; + + if (!iteration) throw new Error(`Failed to create the iteration: ${response.status}`); + + return iteration; + } + + async createEpic(params: CreateEpic): Promise { + const response = await this.client.createEpic(params); + const epic = response?.data ?? null; + + if (!epic) throw new Error(`Failed to create the epic: ${response.status}`); + + return epic; + } +} diff --git a/shortcut-mcp-tools/src/index.ts b/shortcut-mcp-tools/src/index.ts new file mode 100644 index 0000000..25f6ff5 --- /dev/null +++ b/shortcut-mcp-tools/src/index.ts @@ -0,0 +1,18 @@ +// Client exports +export { ShortcutClientWrapper } from "./client/shortcut"; +export { Cache } from "./client/cache"; + +// Tool exports +export { BaseTools } from "./tools/base"; +export { EpicTools } from "./tools/epics"; +export { IterationTools } from "./tools/iterations"; +export { ObjectiveTools } from "./tools/objectives"; +export { StoryTools } from "./tools/stories"; +export { TeamTools } from "./tools/teams"; +export { UserTools } from "./tools/user"; +export { WorkflowTools } from "./tools/workflows"; + +// Utils exports +export * from "./tools/utils/format"; +export * from "./tools/utils/search"; +export * from "./tools/utils/validation"; diff --git a/shortcut-mcp-tools/src/tools/base.test.ts b/shortcut-mcp-tools/src/tools/base.test.ts new file mode 100644 index 0000000..47222fe --- /dev/null +++ b/shortcut-mcp-tools/src/tools/base.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from "bun:test"; +import type { ShortcutClientWrapper } from "@/client/shortcut"; +import { BaseTools } from "./base"; + +describe("BaseTools", () => { + test("toResult", () => { + class TestTools extends BaseTools { + publicToResult(str: string) { + return this.toResult(str); + } + } + const tools = new TestTools({} as ShortcutClientWrapper); + + const result = tools.publicToResult("test"); + + expect(result).toEqual({ content: [{ type: "text", text: "test" }] }); + }); +}); diff --git a/shortcut-mcp-tools/src/tools/base.ts b/shortcut-mcp-tools/src/tools/base.ts new file mode 100644 index 0000000..80419ea --- /dev/null +++ b/shortcut-mcp-tools/src/tools/base.ts @@ -0,0 +1,13 @@ +import type { ShortcutClientWrapper } from "@/client/shortcut"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +/** + * Base class for all tools. + */ +export class BaseTools { + constructor(protected client: ShortcutClientWrapper) {} + + protected toResult(content: string): CallToolResult { + return { content: [{ type: "text", text: content }] }; + } +} diff --git a/shortcut-mcp-tools/src/tools/epics.test.ts b/shortcut-mcp-tools/src/tools/epics.test.ts new file mode 100644 index 0000000..aa29ae7 --- /dev/null +++ b/shortcut-mcp-tools/src/tools/epics.test.ts @@ -0,0 +1,348 @@ +import { beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; +import type { ShortcutClientWrapper } from "@/client/shortcut"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { CreateEpic, Epic, Member, MemberInfo } from "@shortcut/client"; +import { EpicTools } from "./epics"; + +describe("EpicTools", () => { + const mockEpics: Epic[] = [ + { + id: 1, + name: "Epic 1", + description: "Description for Epic 1", + state: "unstarted", + started: false, + completed: false, + archived: false, + deadline: "2025-04-01", + app_url: "https://app.shortcut.com/test/epic/1", + stats: { + num_stories_backlog: 1, + num_stories_unstarted: 2, + num_stories_started: 3, + num_stories_done: 4, + }, + } as Epic, + { + id: 2, + name: "Epic 2", + description: "Description for Epic 2", + state: "started", + started: true, + completed: false, + archived: false, + deadline: null, + app_url: "https://app.shortcut.com/test/epic/2", + stats: { + num_stories_backlog: 1, + num_stories_unstarted: 2, + num_stories_started: 3, + num_stories_done: 4, + }, + } as Epic, + { + id: 3, + name: "Epic 3", + description: "Description for Epic 3", + state: "done", + started: true, + completed: true, + archived: true, + deadline: "2025-03-01", + app_url: "https://app.shortcut.com/test/epic/3", + stats: { + num_stories_backlog: 1, + num_stories_unstarted: 2, + num_stories_started: 3, + num_stories_done: 4, + }, + } as Epic, + ]; + + const mockCurrentUser = { + id: "user1", + mention_name: "testuser", + name: "Test User", + workspace2: { + estimate_scale: [], + }, + } as unknown as Member & MemberInfo; + + const createMockClient = (methods?: object) => + ({ + getCurrentUser: mock(async () => mockCurrentUser), + ...methods, + }) as unknown as ShortcutClientWrapper; + + describe("create method", () => { + test("should register the correct tools with the server", () => { + const mockClient = createMockClient(); + const mockTool = mock(); + const mockServer = { tool: mockTool } as unknown as McpServer; + + EpicTools.create(mockClient, mockServer); + + expect(mockTool).toHaveBeenCalledTimes(3); + + expect(mockTool.mock.calls?.[0]?.[0]).toBe("get-epic"); + expect(mockTool.mock.calls?.[1]?.[0]).toBe("search-epics"); + expect(mockTool.mock.calls?.[2]?.[0]).toBe("create-epic"); + }); + + test("should call correct function from tool", async () => { + const mockClient = createMockClient(); + const mockTool = mock(); + const mockServer = { tool: mockTool } as unknown as McpServer; + + const tools = EpicTools.create(mockClient, mockServer); + + spyOn(tools, "getEpic").mockImplementation(async () => ({ + content: [{ text: "", type: "text" }], + })); + await mockTool.mock.calls?.[0]?.[3]({ epicPublicId: 1 }); + expect(tools.getEpic).toHaveBeenCalledWith(1); + + spyOn(tools, "searchEpics").mockImplementation(async () => ({ + content: [{ text: "(none)", type: "text" }], + })); + await mockTool.mock.calls?.[1]?.[3]({ id: 1 }); + expect(tools.searchEpics).toHaveBeenCalledWith({ id: 1 }); + + spyOn(tools, "createEpic").mockImplementation(async () => ({ + content: [{ text: "", type: "text" }], + })); + await mockTool.mock.calls?.[2]?.[3]({ + name: "Epic 1", + description: "Description for Epic 1", + team: "Test Team", + dueDate: "2025-04-01", + }); + }); + }); + + describe("getEpic method", () => { + const getEpicMock = mock(async (id: number) => mockEpics.find((epic) => epic.id === id)); + const mockClient = createMockClient({ getEpic: getEpicMock }); + + test("should return formatted epic details when epic is found", async () => { + const epicTools = new EpicTools(mockClient); + const result = await epicTools.getEpic(1); + + expect(result.content[0].type).toBe("text"); + expect(String(result.content[0].text).split("\n")).toMatchObject([ + "Epic: 1", + "URL: https://app.shortcut.com/test/epic/1", + "Name: Epic 1", + "Archived: No", + "Completed: No", + "Started: No", + "Due date: 2025-04-01", + "Team: (none)", + "Objective: (none)", + "", + "Stats:", + "- Total stories: 10", + "- Unstarted stories: 3", + "- Stories in progress: 3", + "- Completed stories: 4", + "", + "Description:", + "Description for Epic 1", + ]); + }); + + test("should handle completed and archived epics correctly", async () => { + const epicTools = new EpicTools(mockClient); + const result = await epicTools.getEpic(3); + + expect(result.content[0].type).toBe("text"); + expect(String(result.content[0].text).split("\n")).toMatchObject([ + "Epic: 3", + "URL: https://app.shortcut.com/test/epic/3", + "Name: Epic 3", + "Archived: Yes", + "Completed: Yes", + "Started: Yes", + "Due date: 2025-03-01", + "Team: (none)", + "Objective: (none)", + "", + "Stats:", + "- Total stories: 10", + "- Unstarted stories: 3", + "- Stories in progress: 3", + "- Completed stories: 4", + "", + "Description:", + "Description for Epic 3", + ]); + }); + + test("should handle epics with null deadline", async () => { + const epicTools = new EpicTools(mockClient); + const result = await epicTools.getEpic(2); + + expect(result.content[0].type).toBe("text"); + expect(String(result.content[0].text).split("\n")).toMatchObject([ + "Epic: 2", + "URL: https://app.shortcut.com/test/epic/2", + "Name: Epic 2", + "Archived: No", + "Completed: No", + "Started: Yes", + "Due date: [Not set]", + "Team: (none)", + "Objective: (none)", + "", + "Stats:", + "- Total stories: 10", + "- Unstarted stories: 3", + "- Stories in progress: 3", + "- Completed stories: 4", + "", + "Description:", + "Description for Epic 2", + ]); + }); + + test("should throw error when epic is not found", async () => { + const epicTools = new EpicTools(mockClient); + await expect(() => epicTools.getEpic(999)).toThrow( + "Failed to retrieve Shortcut epic with public ID: 999", + ); + }); + }); + + describe("searchEpics method", () => { + const searchEpicsMock = mock(async (_: string) => ({ + epics: mockEpics, + total: mockEpics.length, + })); + const mockClient = createMockClient({ + searchEpics: searchEpicsMock, + }); + + beforeEach(() => { + searchEpicsMock.mockClear(); + }); + + test("should return formatted list of epics when epics are found", async () => { + const epicTools = new EpicTools(mockClient); + const result = await epicTools.searchEpics({}); + + expect(mockClient.getCurrentUser).toHaveBeenCalled(); + expect(mockClient.searchEpics).toHaveBeenCalled(); + + expect(result.content[0].type).toBe("text"); + expect(String(result.content[0].text).split("\n")).toMatchObject([ + "Result (first 3 shown of 3 total epics found):", + "- 1: Epic 1", + "- 2: Epic 2", + "- 3: Epic 3", + ]); + }); + + test("should return no epics found message when no epics match", async () => { + const epicTools = new EpicTools( + createMockClient({ + searchEpics: mock(async () => ({ epics: [], total: 0 })), + }), + ); + const result = await epicTools.searchEpics({}); + + expect(result.content[0].type).toBe("text"); + expect(result.content[0].text).toBe("Result: No epics found."); + }); + + test("should throw error when epics is null", async () => { + const epicTools = new EpicTools( + createMockClient({ + searchEpics: mock(async () => ({ epics: null, total: 0 })), + }), + ); + await expect(() => epicTools.searchEpics({})).toThrow( + 'Failed to search for epics matching your query: ""', + ); + }); + + test("should handle various search parameters", async () => { + const epicTools = new EpicTools(mockClient); + await epicTools.searchEpics({ + id: 1, + name: "Test Epic", + description: "Test Description", + state: "started", + objective: 123, + owner: "me", + team: "engineering", + isArchived: true, + }); + + expect(searchEpicsMock.mock.calls?.[0]?.[0]).toBe( + 'id:1 name:"Test Epic" description:"Test Description" state:started objective:123 owner:testuser team:engineering is:archived', + ); + }); + + test("should handle 'me' as owner parameter", async () => { + const epicTools = new EpicTools(mockClient); + await epicTools.searchEpics({ owner: "me" }); + expect(searchEpicsMock.mock.calls?.[0]?.[0]).toBe("owner:testuser"); + }); + + test("should handle date parameters", async () => { + const epicTools = new EpicTools(mockClient); + await epicTools.searchEpics({ + created: "2023-01-01", + updated: "2023-01-01..2023-02-01", + completed: "today", + due: "tomorrow", + }); + expect(searchEpicsMock.mock.calls?.[0]?.[0]).toBe( + "created:2023-01-01 updated:2023-01-01..2023-02-01 completed:today due:tomorrow", + ); + }); + + test("should handle boolean parameters", async () => { + const epicTools = new EpicTools(mockClient); + + await epicTools.searchEpics({ + isUnstarted: true, + isStarted: false, + isDone: true, + isArchived: false, + isOverdue: true, + hasOwner: true, + hasComment: false, + hasDeadline: true, + hasLabel: false, + }); + + expect(searchEpicsMock.mock.calls?.[0]?.[0]).toBe( + "is:unstarted !is:started is:done !is:archived is:overdue has:owner !has:comment has:deadline !has:label", + ); + }); + }); + + describe("createEpic method", () => { + const createEpicMock = mock(async (_: CreateEpic) => ({ + id: 1, + name: "Epic 1", + description: "Description for Epic 1", + app_url: "https://app.shortcut.com/test/epic/1", + })); + + const mockClient = createMockClient({ + createEpic: createEpicMock, + }); + + test("should create epic", async () => { + const epicTools = new EpicTools(mockClient); + const result = await epicTools.createEpic({ + name: "Epic 1", + description: "Description for Epic 1", + }); + + expect(result.content[0].text).toBe("Epic created with ID: 1."); + }); + }); +}); diff --git a/shortcut-mcp-tools/src/tools/epics.ts b/shortcut-mcp-tools/src/tools/epics.ts new file mode 100644 index 0000000..49ecb31 --- /dev/null +++ b/shortcut-mcp-tools/src/tools/epics.ts @@ -0,0 +1,136 @@ +import type { ShortcutClientWrapper } from "@/client/shortcut"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; +import { BaseTools } from "./base"; +import { formatAsUnorderedList, formatStats } from "./utils/format"; +import { type QueryParams, buildSearchQuery } from "./utils/search"; +import { date, has, is, user } from "./utils/validation"; + +export class EpicTools extends BaseTools { + static create(client: ShortcutClientWrapper, server: McpServer) { + const tools = new EpicTools(client); + + server.tool( + "get-epic", + "Get a Shortcut epic by public ID", + { epicPublicId: z.number().positive().describe("The public ID of the epic to get") }, + async ({ epicPublicId }) => await tools.getEpic(epicPublicId), + ); + + server.tool( + "search-epics", + "Find Shortcut epics.", + { + id: z.number().optional().describe("Find only epics with the specified public ID"), + name: z.string().optional().describe("Find only epics matching the specified name"), + description: z + .string() + .optional() + .describe("Find only epics matching the specified description"), + state: z + .enum(["unstarted", "started", "done"]) + .optional() + .describe("Find only epics matching the specified state"), + objective: z + .number() + .optional() + .describe("Find only epics matching the specified objective"), + owner: user("owner"), + requester: user("requester"), + team: z + .string() + .optional() + .describe( + "Find only epics matching the specified team. Should be a team's mention name.", + ), + comment: z.string().optional().describe("Find only epics matching the specified comment"), + isUnstarted: is("unstarted"), + isStarted: is("started"), + isDone: is("completed"), + isArchived: is("archived").default(false), + isOverdue: is("overdue"), + hasOwner: has("an owner"), + hasComment: has("a comment"), + hasDeadline: has("a deadline"), + hasLabel: has("a label"), + created: date, + updated: date, + completed: date, + due: date, + }, + async (params) => await tools.searchEpics(params), + ); + + server.tool( + "create-epic", + "Create a new Shortcut epic.", + { + name: z.string().describe("The name of the epic"), + owner: z.string().optional().describe("The user ID of the owner of the epic"), + description: z.string().optional().describe("A description of the epic"), + teamId: z.string().optional().describe("The ID of a team to assign the epic to"), + }, + async (params) => await tools.createEpic(params), + ); + + return tools; + } + + async searchEpics(params: QueryParams) { + const currentUser = await this.client.getCurrentUser(); + const query = await buildSearchQuery(params, currentUser); + const { epics, total } = await this.client.searchEpics(query); + + if (!epics) throw new Error(`Failed to search for epics matching your query: "${query}"`); + if (!epics.length) return this.toResult(`Result: No epics found.`); + + return this.toResult(`Result (first ${epics.length} shown of ${total} total epics found): +${formatAsUnorderedList(epics.map((epic) => `${epic.id}: ${epic.name}`))}`); + } + + async getEpic(epicPublicId: number) { + const epic = await this.client.getEpic(epicPublicId); + + if (!epic) throw new Error(`Failed to retrieve Shortcut epic with public ID: ${epicPublicId}`); + + const currentUser = await this.client.getCurrentUser(); + const showPoints = !!currentUser?.workspace2?.estimate_scale?.length; + + return this.toResult(`Epic: ${epicPublicId} +URL: ${epic.app_url} +Name: ${epic.name} +Archived: ${epic.archived ? "Yes" : "No"} +Completed: ${epic.completed ? "Yes" : "No"} +Started: ${epic.started ? "Yes" : "No"} +Due date: ${epic.deadline ? epic.deadline : "[Not set]"} +Team: ${epic.group_id ? `${epic.group_id}` : "(none)"} +Objective: ${epic.milestone_id ? `${epic.milestone_id}` : "(none)"} + +${formatStats(epic.stats, showPoints)} + +Description: +${epic.description}`); + } + + async createEpic({ + name, + owner, + teamId: group_id, + description, + }: { + name: string; + owner?: string; + teamId?: string; + description?: string; + }): Promise { + const epic = await this.client.createEpic({ + name, + group_id, + owner_ids: owner ? [owner] : undefined, + description, + }); + + return this.toResult(`Epic created with ID: ${epic.id}.`); + } +} diff --git a/shortcut-mcp-tools/src/tools/iterations.test.ts b/shortcut-mcp-tools/src/tools/iterations.test.ts new file mode 100644 index 0000000..0612313 --- /dev/null +++ b/shortcut-mcp-tools/src/tools/iterations.test.ts @@ -0,0 +1,317 @@ +import { describe, expect, mock, spyOn, test } from "bun:test"; +import type { ShortcutClientWrapper } from "@/client/shortcut"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { CreateIteration, Iteration, Member, MemberInfo, Story } from "@shortcut/client"; +import { IterationTools } from "./iterations"; + +describe("IterationTools", () => { + const mockCurrentUser = { + id: "user1", + profile: { + mention_name: "testuser", + name: "Test User", + }, + workspace2: { + estimate_scale: [], + }, + } as unknown as Member & MemberInfo; + + const mockMembers: Member[] = [ + mockCurrentUser, + { + id: "user2", + profile: { + mention_name: "jane", + name: "Jane Smith", + }, + } as Member, + ]; + + const mockStories: Story[] = [ + { + id: 123, + name: "Test Story 1", + story_type: "feature", + owner_ids: ["user1"], + } as Story, + { + id: 456, + name: "Test Story 2", + story_type: "bug", + owner_ids: ["user1", "user2"], + } as Story, + ]; + + const mockIterations: Iteration[] = [ + { + id: 1, + name: "Iteration 1", + description: "Description for Iteration 1", + start_date: "2023-01-01", + end_date: "2023-01-14", + status: "started", + app_url: "https://app.shortcut.com/test/iteration/1", + stats: { + num_stories_backlog: 1, + num_stories_unstarted: 2, + num_stories_started: 3, + num_stories_done: 4, + }, + } as Iteration, + { + id: 2, + name: "Iteration 2", + description: "Description for Iteration 2", + start_date: "2023-01-15", + end_date: "2023-01-28", + status: "unstarted", + app_url: "https://app.shortcut.com/test/iteration/2", + stats: { + num_stories_backlog: 1, + num_stories_unstarted: 2, + num_stories_started: 3, + num_stories_done: 4, + }, + } as Iteration, + ]; + + const createMockClient = (methods?: object) => + ({ + getCurrentUser: mock(async () => mockCurrentUser), + ...methods, + }) as unknown as ShortcutClientWrapper; + + describe("create method", () => { + test("should register the correct tools with the server", () => { + const mockClient = createMockClient(); + const mockTool = mock(); + const mockServer = { tool: mockTool } as unknown as McpServer; + + IterationTools.create(mockClient, mockServer); + + expect(mockTool).toHaveBeenCalledTimes(4); + expect(mockTool.mock.calls?.[0]?.[0]).toBe("get-iteration-stories"); + expect(mockTool.mock.calls?.[1]?.[0]).toBe("get-iteration"); + expect(mockTool.mock.calls?.[2]?.[0]).toBe("search-iterations"); + expect(mockTool.mock.calls?.[3]?.[0]).toBe("create-iteration"); + }); + + test("should call correct function from tool", async () => { + const mockClient = createMockClient(); + const mockTool = mock(); + const mockServer = { tool: mockTool } as unknown as McpServer; + + const tools = IterationTools.create(mockClient, mockServer); + + spyOn(tools, "getIterationStories").mockImplementation(async () => ({ + content: [{ text: "", type: "text" }], + })); + await mockTool.mock.calls?.[0]?.[3]({ iterationPublicId: 1 }); + expect(tools.getIterationStories).toHaveBeenCalledWith(1); + + spyOn(tools, "getIteration").mockImplementation(async () => ({ + content: [{ text: "", type: "text" }], + })); + await mockTool.mock.calls?.[1]?.[3]({ iterationPublicId: 1 }); + expect(tools.getIteration).toHaveBeenCalledWith(1); + + spyOn(tools, "searchIterations").mockImplementation(async () => ({ + content: [{ text: "(none)", type: "text" }], + })); + await mockTool.mock.calls?.[2]?.[3]({ name: "test" }); + expect(tools.searchIterations).toHaveBeenCalledWith({ name: "test" }); + + spyOn(tools, "createIteration").mockImplementation(async () => ({ + content: [{ text: "", type: "text" }], + })); + await mockTool.mock.calls?.[3]?.[3]({ + name: "Test Iteration", + description: "Test Iteration created by the Shortcut MCP server", + startDate: "2023-01-01", + endDate: "2023-01-14", + groupId: "group1", + }); + }); + }); + + describe("getIterationStories method", () => { + const listIterationStoriesMock = mock(async () => ({ stories: mockStories })); + const getUserMapMock = mock(async (ids: string[]) => { + const map = new Map(); + for (const id of ids) { + const member = mockMembers.find((m) => m.id === id); + if (member) map.set(id, member); + } + return map; + }); + + const mockClient = createMockClient({ + listIterationStories: listIterationStoriesMock, + getUserMap: getUserMapMock, + }); + + test("should return formatted list of stories in an iteration", async () => { + const iterationTools = new IterationTools(mockClient); + const result = await iterationTools.getIterationStories(1); + + expect(result.content[0].type).toBe("text"); + expect(String(result.content[0].text).split("\n")).toMatchObject([ + "Result (2 stories found):", + "- sc-123: Test Story 1 (Type: feature, State: Not Started, Team: (none), Epic: (none), Iteration: (none), Owners: @testuser)", + "- sc-456: Test Story 2 (Type: bug, State: Not Started, Team: (none), Epic: (none), Iteration: (none), Owners: @testuser, @jane)", + ]); + }); + + test("should throw error when stories are not found", async () => { + const iterationTools = new IterationTools( + createMockClient({ + listIterationStories: mock(async () => ({ stories: null })), + }), + ); + + await expect(() => iterationTools.getIterationStories(1)).toThrow( + "Failed to retrieve Shortcut stories in iteration with public ID: 1.", + ); + }); + }); + + describe("searchIterations method", () => { + const searchIterationsMock = mock(async () => ({ + iterations: mockIterations, + total: mockIterations.length, + })); + + test("should return formatted list of iterations when iterations are found", async () => { + const iterationTools = new IterationTools( + createMockClient({ + searchIterations: searchIterationsMock, + }), + ); + const result = await iterationTools.searchIterations({}); + + expect(result.content[0].type).toBe("text"); + expect(String(result.content[0].text).split("\n")).toMatchObject([ + "Result (first 2 shown of 2 total iterations found):", + "- 1: Iteration 1 (Start date: 2023-01-01, End date: 2023-01-14)", + "- 2: Iteration 2 (Start date: 2023-01-15, End date: 2023-01-28)", + ]); + }); + + test("should return no iterations found message when no iterations exist", async () => { + const iterationTools = new IterationTools( + createMockClient({ + searchIterations: mock(async () => ({ iterations: [], total: 0 })), + }), + ); + + const result = await iterationTools.searchIterations({}); + + expect(result.content[0].type).toBe("text"); + expect(result.content[0].text).toBe("Result: No iterations found."); + }); + + test("should throw error when iterations search fails", async () => { + const iterationTools = new IterationTools( + createMockClient({ + searchIterations: mock(async () => ({ iterations: null, total: 0 })), + }), + ); + + await expect(() => iterationTools.searchIterations({})).toThrow( + "Failed to search for iterations matching your query", + ); + }); + }); + + describe("getIteration method", () => { + const getIterationMock = mock(async (id: number) => + mockIterations.find((iteration) => iteration.id === id), + ); + + test("should return formatted iteration details when iteration is found", async () => { + const iterationTools = new IterationTools( + createMockClient({ + getIteration: getIterationMock, + }), + ); + const result = await iterationTools.getIteration(1); + + expect(result.content[0].type).toBe("text"); + expect(String(result.content[0].text).split("\n")).toMatchObject([ + "Iteration: 1", + "Url: https://app.shortcut.com/test/iteration/1", + "Name: Iteration 1", + "Start date: 2023-01-01", + "End date: 2023-01-14", + "Completed: No", + "Started: Yes", + "Team: (none)", + "", + "Stats:", + "- Total stories: 10", + "- Unstarted stories: 3", + "- Stories in progress: 3", + "- Completed stories: 4", + "", + "Description:", + "Description for Iteration 1", + ]); + }); + + test("should handle iteration not found", async () => { + const iterationTools = new IterationTools( + createMockClient({ + getIteration: mock(async () => null), + }), + ); + + await expect(() => iterationTools.getIteration(999)).toThrow( + "Failed to retrieve Shortcut iteration with public ID: 999.", + ); + }); + + test("should handle completed iteration", async () => { + const iterationTools = new IterationTools( + createMockClient({ + getIteration: mock(async () => ({ + ...mockIterations[0], + status: "completed", + })), + }), + ); + + const result = await iterationTools.getIteration(1); + + expect(result.content[0].type).toBe("text"); + expect(result.content[0].text).toContain("Completed: Yes"); + expect(result.content[0].text).toContain("Started: No"); + }); + }); + + describe("createIteration method", () => { + const createIterationMock = mock(async (_: CreateIteration) => ({ + id: 1, + name: "Iteration 1", + description: "Description for Iteration 1", + start_date: "2023-01-01", + end_date: "2023-01-14", + app_url: "https://app.shortcut.com/test/iteration/1", + })); + + const mockClient = createMockClient({ + createIteration: createIterationMock, + }); + + test("should create a new iteration and return its details", async () => { + const iterationTools = new IterationTools(mockClient); + const result = await iterationTools.createIteration({ + name: "Test Iteration", + startDate: "2023-01-01", + endDate: "2023-01-14", + description: "Test Iteration created by the Shortcut MCP server", + }); + + expect(result.content[0].text).toBe("Iteration created with ID: 1."); + }); + }); +}); diff --git a/shortcut-mcp-tools/src/tools/iterations.ts b/shortcut-mcp-tools/src/tools/iterations.ts new file mode 100644 index 0000000..1be6a6d --- /dev/null +++ b/shortcut-mcp-tools/src/tools/iterations.ts @@ -0,0 +1,152 @@ +import type { ShortcutClientWrapper } from "@/client/shortcut"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; +import { BaseTools } from "./base"; +import { formatAsUnorderedList, formatStats, formatStoryList } from "./utils/format"; +import { type QueryParams, buildSearchQuery } from "./utils/search"; +import { date } from "./utils/validation"; + +export class IterationTools extends BaseTools { + static create(client: ShortcutClientWrapper, server: McpServer) { + const tools = new IterationTools(client); + + server.tool( + "get-iteration-stories", + "Get stories in a specific iteration by iteration public ID", + { iterationPublicId: z.number().positive().describe("The public ID of the iteration") }, + async ({ iterationPublicId }) => await tools.getIterationStories(iterationPublicId), + ); + + server.tool( + "get-iteration", + "Get a Shortcut iteration by public ID", + { + iterationPublicId: z.number().positive().describe("The public ID of the iteration to get"), + }, + async ({ iterationPublicId }) => await tools.getIteration(iterationPublicId), + ); + + server.tool( + "search-iterations", + "Find Shortcut iterations.", + { + id: z.number().optional().describe("Find only iterations with the specified public ID"), + name: z.string().optional().describe("Find only iterations matching the specified name"), + description: z + .string() + .optional() + .describe("Find only iterations matching the specified description"), + state: z + .enum(["started", "unstarted", "done"]) + .optional() + .describe("Find only iterations matching the specified state"), + team: z + .string() + .optional() + .describe( + "Find only iterations matching the specified team. This can be a team ID or mention name.", + ), + created: date, + updated: date, + startDate: date, + endDate: date, + }, + async (params) => await tools.searchIterations(params), + ); + + server.tool( + "create-iteration", + "Create a new Shortcut iteration", + { + name: z.string().describe("The name of the iteration"), + startDate: z.string().describe("The start date of the iteration in YYYY-MM-DD format"), + endDate: z.string().describe("The end date of the iteration in YYYY-MM-DD format"), + teamId: z.string().optional().describe("The ID of a team to assign the iteration to"), + description: z.string().optional().describe("A description of the iteration"), + }, + async (params) => await tools.createIteration(params), + ); + + return tools; + } + + async getIterationStories(iterationPublicId: number) { + const { stories } = await this.client.listIterationStories(iterationPublicId); + + if (!stories) + throw new Error( + `Failed to retrieve Shortcut stories in iteration with public ID: ${iterationPublicId}.`, + ); + + const owners = await this.client.getUserMap(stories.flatMap((story) => story.owner_ids)); + + return this.toResult(`Result (${stories.length} stories found): +${formatStoryList(stories, owners)}`); + } + + async searchIterations(params: QueryParams) { + const currentUser = await this.client.getCurrentUser(); + const query = await buildSearchQuery(params, currentUser); + const { iterations, total } = await this.client.searchIterations(query); + + if (!iterations) + throw new Error(`Failed to search for iterations matching your query: "${query}".`); + if (!iterations.length) return this.toResult(`Result: No iterations found.`); + + return this.toResult(`Result (first ${iterations.length} shown of ${total} total iterations found): +${formatAsUnorderedList(iterations.map((iteration) => `${iteration.id}: ${iteration.name} (Start date: ${iteration.start_date}, End date: ${iteration.end_date})`))}`); + } + + async getIteration(iterationPublicId: number) { + const iteration = await this.client.getIteration(iterationPublicId); + + if (!iteration) + throw new Error( + `Failed to retrieve Shortcut iteration with public ID: ${iterationPublicId}.`, + ); + + const currentUser = await this.client.getCurrentUser(); + const showPoints = !!currentUser?.workspace2?.estimate_scale?.length; + + return this.toResult(`Iteration: ${iterationPublicId} +Url: ${iteration.app_url} +Name: ${iteration.name} +Start date: ${iteration.start_date} +End date: ${iteration.end_date} +Completed: ${iteration.status === "completed" ? "Yes" : "No"} +Started: ${iteration.status === "started" ? "Yes" : "No"} +Team: ${iteration.group_ids?.length ? `${iteration.group_ids.join(", ")}` : "(none)"} + +${formatStats(iteration.stats, showPoints)} + +Description: +${iteration.description}`); + } + + async createIteration({ + name, + startDate, + endDate, + teamId, + description, + }: { + name: string; + startDate: string; + endDate: string; + teamId?: string; + description?: string; + }): Promise { + const iteration = await this.client.createIteration({ + name, + start_date: startDate, + end_date: endDate, + group_ids: teamId ? [teamId] : undefined, + description, + }); + + if (!iteration) throw new Error(`Failed to create the iteration.`); + + return this.toResult(`Iteration created with ID: ${iteration.id}.`); + } +} diff --git a/shortcut-mcp-tools/src/tools/objectives.test.ts b/shortcut-mcp-tools/src/tools/objectives.test.ts new file mode 100644 index 0000000..709cfdd --- /dev/null +++ b/shortcut-mcp-tools/src/tools/objectives.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, mock, spyOn, test } from "bun:test"; +import type { ShortcutClientWrapper } from "@/client/shortcut"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { Milestone } from "@shortcut/client"; +import { ObjectiveTools } from "./objectives"; + +describe("ObjectiveTools", () => { + const mockCurrentUser = { + id: "user1", + mention_name: "testuser", + name: "Test User", + }; + + const mockMilestones: Milestone[] = [ + { + id: 1, + name: "Objective 1", + description: "Description for Objective 1", + app_url: "https://app.shortcut.com/test/milestone/1", + archived: false, + completed: false, + started: true, + } as Milestone, + { + id: 2, + name: "Objective 2", + description: "Description for Objective 2", + app_url: "https://app.shortcut.com/test/milestone/2", + archived: false, + completed: true, + started: true, + } as Milestone, + ]; + + describe("create method", () => { + test("should register the correct tools with the server", () => { + const mockClient = {} as ShortcutClientWrapper; + const mockTool = mock(); + const mockServer = { tool: mockTool } as unknown as McpServer; + + ObjectiveTools.create(mockClient, mockServer); + + expect(mockTool).toHaveBeenCalledTimes(2); + expect(mockTool.mock.calls?.[0]?.[0]).toBe("get-objective"); + expect(mockTool.mock.calls?.[1]?.[0]).toBe("search-objectives"); + }); + + test("should call correct function from tool", async () => { + const mockClient = {} as ShortcutClientWrapper; + const mockTool = mock(); + const mockServer = { tool: mockTool } as unknown as McpServer; + + const tools = ObjectiveTools.create(mockClient, mockServer); + + spyOn(tools, "getObjective").mockImplementation(async () => ({ + content: [{ text: "", type: "text" }], + })); + await mockTool.mock.calls?.[0]?.[3]({ objectivePublicId: 1 }); + expect(tools.getObjective).toHaveBeenCalledWith(1); + + spyOn(tools, "searchObjectives").mockImplementation(async () => ({ + content: [{ text: "(none)", type: "text" }], + })); + await mockTool.mock.calls?.[1]?.[3]({ name: "test" }); + expect(tools.searchObjectives).toHaveBeenCalledWith({ name: "test" }); + }); + }); + + describe("searchObjectives method", () => { + const searchMilestonesMock = mock(async () => ({ + milestones: mockMilestones, + total: mockMilestones.length, + })); + const getCurrentUserMock = mock(async () => mockCurrentUser); + const mockClient = { + searchMilestones: searchMilestonesMock, + getCurrentUser: getCurrentUserMock, + } as unknown as ShortcutClientWrapper; + + test("should return formatted list of objectives when objectives are found", async () => { + const objectiveTools = new ObjectiveTools(mockClient); + const result = await objectiveTools.searchObjectives({}); + + expect(result.content[0].type).toBe("text"); + expect(String(result.content[0].text).split("\n")).toMatchObject([ + "Result (first 2 shown of 2 total milestones found):", + "- 1: Objective 1", + "- 2: Objective 2", + ]); + }); + + test("should return no objectives found message when no objectives exist", async () => { + const objectiveTools = new ObjectiveTools({ + ...mockClient, + searchMilestones: mock(async () => ({ milestones: [], total: 0 })), + } as unknown as ShortcutClientWrapper); + + const result = await objectiveTools.searchObjectives({}); + + expect(result.content[0].type).toBe("text"); + expect(result.content[0].text).toBe("Result: No milestones found."); + }); + + test("should throw error when objectives search fails", async () => { + const objectiveTools = new ObjectiveTools({ + ...mockClient, + searchMilestones: mock(async () => ({ milestones: null, total: 0 })), + } as unknown as ShortcutClientWrapper); + + await expect(() => objectiveTools.searchObjectives({})).toThrow( + "Failed to search for milestones matching your query", + ); + }); + }); + + describe("getObjective method", () => { + const getMilestoneMock = mock(async (id: number) => + mockMilestones.find((milestone) => milestone.id === id), + ); + const mockClient = { getMilestone: getMilestoneMock } as unknown as ShortcutClientWrapper; + + test("should return formatted objective details when objective is found", async () => { + const objectiveTools = new ObjectiveTools(mockClient); + const result = await objectiveTools.getObjective(1); + + expect(result.content[0].type).toBe("text"); + expect(String(result.content[0].text).split("\n")).toMatchObject([ + "Objective: 1", + "Url: https://app.shortcut.com/test/milestone/1", + "Name: Objective 1", + "Archived: No", + "Completed: No", + "Started: Yes", + "", + "Description:", + "Description for Objective 1", + ]); + }); + + test("should handle objective not found", async () => { + const objectiveTools = new ObjectiveTools({ + getMilestone: mock(async () => null), + } as unknown as ShortcutClientWrapper); + + await expect(() => objectiveTools.getObjective(999)).toThrow( + "Failed to retrieve Shortcut objective with public ID: 999", + ); + }); + + test("should handle completed objective", async () => { + const objectiveTools = new ObjectiveTools({ + getMilestone: mock(async () => mockMilestones[1]), + } as unknown as ShortcutClientWrapper); + + const result = await objectiveTools.getObjective(2); + + expect(result.content[0].type).toBe("text"); + expect(result.content[0].text).toContain("Completed: Yes"); + }); + }); +}); diff --git a/shortcut-mcp-tools/src/tools/objectives.ts b/shortcut-mcp-tools/src/tools/objectives.ts new file mode 100644 index 0000000..81dcc26 --- /dev/null +++ b/shortcut-mcp-tools/src/tools/objectives.ts @@ -0,0 +1,86 @@ +import type { ShortcutClientWrapper } from "@/client/shortcut"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { BaseTools } from "./base"; +import { formatAsUnorderedList } from "./utils/format"; +import { type QueryParams, buildSearchQuery } from "./utils/search"; +import { date, has, is, user } from "./utils/validation"; + +export class ObjectiveTools extends BaseTools { + static create(client: ShortcutClientWrapper, server: McpServer) { + const tools = new ObjectiveTools(client); + + server.tool( + "get-objective", + "Get a Shortcut objective by public ID", + { + objectivePublicId: z.number().positive().describe("The public ID of the objective to get"), + }, + async ({ objectivePublicId }) => await tools.getObjective(objectivePublicId), + ); + + server.tool( + "search-objectives", + "Find Shortcut objectives.", + { + id: z.number().optional().describe("Find objectives matching the specified id"), + name: z.string().optional().describe("Find objectives matching the specified name"), + description: z + .string() + .optional() + .describe("Find objectives matching the specified description"), + state: z + .enum(["unstarted", "started", "done"]) + .optional() + .describe("Find objectives matching the specified state"), + owner: user("owner"), + requester: user("requester"), + team: z + .string() + .optional() + .describe("Find objectives matching the specified team. Should be a team mention name."), + isUnstarted: is("unstarted"), + isStarted: is("started"), + isDone: is("completed"), + isArchived: is("archived"), + hasOwner: has("an owner"), + created: date, + updated: date, + completed: date, + }, + async (params) => await tools.searchObjectives(params), + ); + + return tools; + } + + async searchObjectives(params: QueryParams) { + const currentUser = await this.client.getCurrentUser(); + const query = await buildSearchQuery(params, currentUser); + const { milestones, total } = await this.client.searchMilestones(query); + + if (!milestones) + throw new Error(`Failed to search for milestones matching your query: "${query}"`); + if (!milestones.length) return this.toResult(`Result: No milestones found.`); + + return this.toResult(`Result (first ${milestones.length} shown of ${total} total milestones found): +${formatAsUnorderedList(milestones.map((milestone) => `${milestone.id}: ${milestone.name}`))}`); + } + + async getObjective(objectivePublicId: number) { + const objective = await this.client.getMilestone(objectivePublicId); + + if (!objective) + throw new Error(`Failed to retrieve Shortcut objective with public ID: ${objectivePublicId}`); + + return this.toResult(`Objective: ${objectivePublicId} +Url: ${objective.app_url} +Name: ${objective.name} +Archived: ${objective.archived ? "Yes" : "No"} +Completed: ${objective.completed ? "Yes" : "No"} +Started: ${objective.started ? "Yes" : "No"} + +Description: +${objective.description}`); + } +} diff --git a/shortcut-mcp-tools/src/tools/stories.test.ts b/shortcut-mcp-tools/src/tools/stories.test.ts new file mode 100644 index 0000000..fc98237 --- /dev/null +++ b/shortcut-mcp-tools/src/tools/stories.test.ts @@ -0,0 +1,662 @@ +import { beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; +import type { ShortcutClientWrapper } from "@/client/shortcut"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { + Branch, + CreateStoryCommentParams, + CreateStoryParams, + Member, + MemberInfo, + PullRequest, + Story, + StoryComment, + Task, + UpdateStory, + Workflow, +} from "@shortcut/client"; +import { StoryTools } from "./stories"; + +describe("StoryTools", () => { + const mockCurrentUser = { + id: "user1", + mention_name: "testuser", + name: "Test User", + } as MemberInfo; + + const mockMembers: Member[] = [ + { + id: mockCurrentUser.id, + profile: { + mention_name: mockCurrentUser.mention_name, + name: mockCurrentUser.name, + }, + } as Member, + { + id: "user2", + profile: { + mention_name: "jane", + name: "Jane Smith", + }, + } as Member, + ]; + + const mockStories: Story[] = [ + { + id: 123, + name: "Test Story 1", + story_type: "feature", + app_url: "https://app.shortcut.com/test/story/123", + description: "Description for Test Story 1", + archived: false, + completed: false, + started: true, + blocked: false, + blocker: false, + deadline: "2023-12-31", + owner_ids: ["user1"], + branches: [ + { + id: 1, + name: "user1/sc-123/test-story-1", + created_at: "2023-01-01T12:00:00Z", + pull_requests: [ + { + id: 1, + title: "Test PR 1", + url: "https://github.com/user1/repo1/pull/1", + merged: true, + closed: true, + } as unknown as PullRequest, + ], + } as unknown as Branch, + ], + comments: [ + { + id: "comment1", + author_id: "user1", + text: "This is a comment", + created_at: "2023-01-01T12:00:00Z", + } as unknown as StoryComment, + ], + formatted_vcs_branch_name: "user1/sc-123/test-story-1", + external_links: ["https://example.com", "https://example2.com"], + tasks: [ + { + id: 1, + description: "task 1", + complete: false, + }, + { + id: 2, + description: "task 2", + complete: true, + }, + ] satisfies Partial[], + } as unknown as Story, + { + id: 456, + name: "Test Story 2", + branches: [], + external_links: [], + story_type: "bug", + app_url: "https://app.shortcut.com/test/story/456", + description: "Description for Test Story 2", + archived: false, + completed: true, + started: true, + blocked: false, + blocker: false, + deadline: null, + owner_ids: ["user1", "user2"], + comments: [], + } as unknown as Story, + ]; + + const mockWorkflow: Workflow = { + id: 1, + name: "Test Workflow", + default_state_id: 101, + states: [ + { id: 101, name: "Unstarted", type: "unstarted" }, + { id: 102, name: "Started", type: "started" }, + ], + } as Workflow; + + const mockTeam = { + id: "team1", + name: "Test Team", + workflow_ids: [1], + }; + + describe("create method", () => { + test("should register the correct tools with the server", () => { + const mockClient = {} as ShortcutClientWrapper; + const mockTool = mock(); + const mockServer = { tool: mockTool } as unknown as McpServer; + + StoryTools.create(mockClient, mockServer); + + expect(mockTool).toHaveBeenCalledTimes(7); + expect(mockTool.mock.calls?.[0]?.[0]).toBe("get-story-branch-name"); + expect(mockTool.mock.calls?.[1]?.[0]).toBe("get-story"); + expect(mockTool.mock.calls?.[2]?.[0]).toBe("search-stories"); + expect(mockTool.mock.calls?.[3]?.[0]).toBe("create-story"); + expect(mockTool.mock.calls?.[4]?.[0]).toBe("assign-current-user-as-owner"); + expect(mockTool.mock.calls?.[5]?.[0]).toBe("unassign-current-user-as-owner"); + expect(mockTool.mock.calls?.[6]?.[0]).toBe("create-story-comment"); + }); + + test("should call correct function from tool", async () => { + const mockClient = {} as ShortcutClientWrapper; + const mockTool = mock(); + const mockServer = { tool: mockTool } as unknown as McpServer; + + const tools = StoryTools.create(mockClient, mockServer); + + spyOn(tools, "getStoryBranchName").mockImplementation(async () => ({ + content: [{ text: "", type: "text" }], + })); + await mockTool.mock.calls?.[0]?.[3]({ storyPublicId: 123 }); + expect(tools.getStoryBranchName).toHaveBeenCalledWith(123); + + spyOn(tools, "getStory").mockImplementation(async () => ({ + content: [{ text: "", type: "text" }], + })); + await mockTool.mock.calls?.[1]?.[3]({ storyPublicId: 123 }); + expect(tools.getStory).toHaveBeenCalledWith(123); + + spyOn(tools, "searchStories").mockImplementation(async () => ({ + content: [{ text: "", type: "text" }], + })); + await mockTool.mock.calls?.[2]?.[3]({ id: 123 }); + expect(tools.searchStories).toHaveBeenCalledWith({ id: 123 }); + + spyOn(tools, "createStory").mockImplementation(async () => ({ + content: [{ text: "", type: "text" }], + })); + await mockTool.mock.calls?.[3]?.[3]({ name: "Test Story 1" }); + expect(tools.createStory).toHaveBeenCalledWith({ name: "Test Story 1" }); + + spyOn(tools, "assignCurrentUserAsOwner").mockImplementation(async () => ({ + content: [{ text: "", type: "text" }], + })); + await mockTool.mock.calls?.[4]?.[3]({ storyPublicId: 123 }); + expect(tools.assignCurrentUserAsOwner).toHaveBeenCalledWith(123); + + spyOn(tools, "unassignCurrentUserAsOwner").mockImplementation(async () => ({ + content: [{ text: "", type: "text" }], + })); + await mockTool.mock.calls?.[5]?.[3]({ storyPublicId: 123 }); + expect(tools.unassignCurrentUserAsOwner).toHaveBeenCalledWith(123); + + spyOn(tools, "createStoryComment").mockImplementation(async () => ({ + content: [{ text: "", type: "text" }], + })); + await mockTool.mock.calls?.[6]?.[3]({ storyPublicId: 123, text: "Test comment" }); + expect(tools.createStoryComment).toHaveBeenCalledWith({ + storyPublicId: 123, + text: "Test comment", + }); + }); + }); + + describe("getStory method", () => { + const getStoryMock = mock(async (id: number) => mockStories.find((story) => story.id === id)); + const getUserMapMock = mock(async (ids: string[]) => { + const map = new Map(); + for (const id of ids) { + const member = mockMembers.find((m) => m.id === id); + if (member) map.set(id, member); + } + return map; + }); + + const mockClient = { + getStory: getStoryMock, + getUserMap: getUserMapMock, + } as unknown as ShortcutClientWrapper; + + test("should return formatted story details when story is found", async () => { + const storyTools = new StoryTools(mockClient); + const result = await storyTools.getStory(123); + + expect(result.content[0].type).toBe("text"); + expect(String(result.content[0].text).split("\n")).toMatchObject([ + "Story: sc-123", + "URL: https://app.shortcut.com/test/story/123", + "Name: Test Story 1", + "Type: feature", + "Archived: No", + "Completed: No", + "Started: Yes", + "Blocked: No", + "Blocking: No", + "Due date: 2023-12-31", + "Team: (none)", + "Owners:", + "- id=user1 @testuser", + "Epic: (none)", + "Iteration: (none)", + "", + "Description:", + "Description for Test Story 1", + "", + "External Links:", + "- https://example.com", + "- https://example2.com", + "", + "Pull Requests:", + "- Title: Test PR 1, Merged: Yes, URL: https://github.com/user1/repo1/pull/1", + "", + "Tasks:", + "- [ ] task 1", + "- [X] task 2", + "", + "Comments:", + "- From: @testuser on 2023-01-01T12:00:00Z.", + "This is a comment", + ]); + }); + + test("should handle story not found", async () => { + const storyTools = new StoryTools({ + getStory: mock(async () => null), + } as unknown as ShortcutClientWrapper); + + await expect(() => storyTools.getStory(999)).toThrow( + "Failed to retrieve Shortcut story with public ID: 999.", + ); + }); + + test("should handle story with null deadline", async () => { + const storyTools = new StoryTools({ + getStory: mock(async () => mockStories[1]), + getUserMap: getUserMapMock, + } as unknown as ShortcutClientWrapper); + + const result = await storyTools.getStory(456); + + expect(result.content[0].type).toBe("text"); + expect(result.content[0].text).toContain("Due date: (none)"); + }); + }); + + describe("searchStories method", () => { + const searchStoriesMock = mock(async () => ({ + stories: mockStories, + total: mockStories.length, + })); + const getCurrentUserMock = mock(async () => mockCurrentUser); + const getUserMapMock = mock(async (ids: string[]) => { + const map = new Map(); + for (const id of ids) { + const member = mockMembers.find((m) => m.id === id); + if (member) map.set(id, member); + } + return map; + }); + + const mockClient = { + searchStories: searchStoriesMock, + getCurrentUser: getCurrentUserMock, + getUserMap: getUserMapMock, + } as unknown as ShortcutClientWrapper; + + test("should return formatted list of stories when stories are found", async () => { + const storyTools = new StoryTools(mockClient); + const result = await storyTools.searchStories({}); + + expect(result.content[0].type).toBe("text"); + expect(String(result.content[0].text).split("\n")).toMatchObject([ + "Result (first 2 shown of 2 total stories found):", + "- sc-123: Test Story 1 (Type: feature, State: In Progress, Team: (none), Epic: (none), Iteration: (none), Owners: @testuser)", + "- sc-456: Test Story 2 (Type: bug, State: Completed, Team: (none), Epic: (none), Iteration: (none), Owners: @testuser, @jane)", + ]); + }); + + test("should return no stories found message when no stories exist", async () => { + const storyTools = new StoryTools({ + searchStories: mock(async () => ({ stories: [], total: 0 })), + getCurrentUser: getCurrentUserMock, + } as unknown as ShortcutClientWrapper); + + const result = await storyTools.searchStories({}); + + expect(result.content[0].type).toBe("text"); + expect(result.content[0].text).toBe("Result: No stories found."); + }); + + test("should throw error when stories search fails", async () => { + const storyTools = new StoryTools({ + searchStories: mock(async () => ({ stories: null, total: 0 })), + getCurrentUser: getCurrentUserMock, + } as unknown as ShortcutClientWrapper); + + await expect(() => storyTools.searchStories({})).toThrow( + "Failed to search for stories matching your query", + ); + }); + }); + + describe("createStory method", () => { + const createStoryMock = mock(async (_: CreateStoryParams) => ({ id: 789 })); + const getTeamMock = mock(async () => mockTeam); + const getWorkflowMock = mock(async () => mockWorkflow); + + const mockClient = { + createStory: createStoryMock, + getTeam: getTeamMock, + getWorkflow: getWorkflowMock, + } as unknown as ShortcutClientWrapper; + + beforeEach(() => { + createStoryMock.mockClear(); + }); + + test("should create a story with workflow specified", async () => { + const storyTools = new StoryTools(mockClient); + const result = await storyTools.createStory({ + name: "New Story", + description: "Description for New Story", + type: "feature", + workflow: 1, + }); + + expect(result.content[0].type).toBe("text"); + expect(result.content[0].text).toBe("Created story: 789"); + expect(createStoryMock).toHaveBeenCalledTimes(1); + expect(createStoryMock.mock.calls?.[0]?.[0]).toMatchObject({ + name: "New Story", + description: "Description for New Story", + story_type: "feature", + workflow_state_id: mockWorkflow.default_state_id, + }); + }); + + test("should create a story with team specified", async () => { + const storyTools = new StoryTools(mockClient); + const result = await storyTools.createStory({ + name: "New Story", + description: "Description for New Story", + type: "bug", + team: "team1", + }); + + expect(result.content[0].type).toBe("text"); + expect(result.content[0].text).toBe("Created story: 789"); + expect(createStoryMock).toHaveBeenCalledTimes(1); + expect(createStoryMock.mock.calls?.[0]?.[0]).toMatchObject({ + name: "New Story", + description: "Description for New Story", + story_type: "bug", + group_id: "team1", + workflow_state_id: mockWorkflow.default_state_id, + }); + }); + + test("should throw error when neither team nor workflow is specified", async () => { + const storyTools = new StoryTools(mockClient); + + await expect(() => + storyTools.createStory({ + name: "New Story", + type: "feature", + }), + ).toThrow("Team or Workflow has to be specified"); + }); + + test("should throw error when workflow is not found", async () => { + const storyTools = new StoryTools({ + ...mockClient, + getWorkflow: mock(async () => null), + } as unknown as ShortcutClientWrapper); + + await expect(() => + storyTools.createStory({ + name: "New Story", + type: "feature", + workflow: 999, + }), + ).toThrow("Failed to find workflow"); + }); + }); + + describe("assignCurrentUserAsOwner method", () => { + const getStoryMock = mock(async () => mockStories[0]); + const getCurrentUserMock = mock(async () => mockCurrentUser); + const updateStoryMock = mock(async (_id: number, _args: UpdateStory) => ({ + id: 123, + })); + + const mockClient = { + getStory: getStoryMock, + getCurrentUser: getCurrentUserMock, + updateStory: updateStoryMock, + } as unknown as ShortcutClientWrapper; + + beforeEach(() => { + updateStoryMock.mockClear(); + }); + + test("should assign current user as owner", async () => { + const storyTools = new StoryTools(mockClient); + getCurrentUserMock.mockImplementationOnce(async () => ({ + ...mockCurrentUser, + id: "different-user", + })); + const result = await storyTools.assignCurrentUserAsOwner(123); + + expect(result.content[0].type).toBe("text"); + expect(result.content[0].text).toBe("Assigned current user as owner of story sc-123"); + expect(updateStoryMock).toHaveBeenCalledTimes(1); + expect(updateStoryMock.mock.calls?.[0]?.[0]).toBe(123); + expect(updateStoryMock.mock.calls?.[0]?.[1]).toMatchObject({ + owner_ids: ["user1", "different-user"], + }); + }); + + test("should handle user already assigned", async () => { + const storyTools = new StoryTools({ + ...mockClient, + getStory: mock(async () => ({ + ...mockStories[0], + owner_ids: ["user1"], + })), + } as unknown as ShortcutClientWrapper); + + const result = await storyTools.assignCurrentUserAsOwner(123); + + expect(result.content[0].type).toBe("text"); + expect(result.content[0].text).toBe("Current user is already an owner of story sc-123"); + expect(updateStoryMock).not.toHaveBeenCalled(); + }); + + test("should throw error when story is not found", async () => { + const storyTools = new StoryTools({ + ...mockClient, + getStory: mock(async () => null), + } as unknown as ShortcutClientWrapper); + + await expect(() => storyTools.assignCurrentUserAsOwner(999)).toThrow( + "Failed to retrieve Shortcut story with public ID: 999", + ); + }); + }); + + describe("unassignCurrentUserAsOwner method", () => { + const getStoryMock = mock(async () => ({ + ...mockStories[0], + owner_ids: ["user1", "user2"], + })); + const getCurrentUserMock = mock(async () => mockCurrentUser); + const updateStoryMock = mock(async (_id: number, _args: UpdateStory) => ({ + id: 123, + })); + + const mockClient = { + getStory: getStoryMock, + getCurrentUser: getCurrentUserMock, + updateStory: updateStoryMock, + } as unknown as ShortcutClientWrapper; + + beforeEach(() => { + updateStoryMock.mockClear(); + }); + + test("should unassign current user as owner", async () => { + const storyTools = new StoryTools(mockClient); + const result = await storyTools.unassignCurrentUserAsOwner(123); + + expect(result.content[0].type).toBe("text"); + expect(result.content[0].text).toBe("Unassigned current user as owner of story sc-123"); + expect(updateStoryMock).toHaveBeenCalledTimes(1); + expect(updateStoryMock.mock.calls?.[0]?.[0]).toBe(123); + expect(updateStoryMock.mock.calls?.[0]?.[1]).toMatchObject({ + owner_ids: ["user2"], + }); + }); + + test("should handle user not assigned", async () => { + const storyTools = new StoryTools({ + ...mockClient, + getStory: mock(async () => ({ + ...mockStories[0], + owner_ids: ["user2"], + })), + } as unknown as ShortcutClientWrapper); + + const result = await storyTools.unassignCurrentUserAsOwner(123); + + expect(result.content[0].type).toBe("text"); + expect(result.content[0].text).toBe("Current user is not an owner of story sc-123"); + expect(updateStoryMock).not.toHaveBeenCalled(); + }); + }); + + describe("getStoryBranchName method", () => { + const getStoryMock = mock(async () => mockStories[0]); + const getCurrentUserMock = mock(async () => mockCurrentUser); + + const mockClient = { + getStory: getStoryMock, + getCurrentUser: getCurrentUserMock, + } as unknown as ShortcutClientWrapper; + + test("should return branch name from api for story", async () => { + const storyTools = new StoryTools(mockClient); + const result = await storyTools.getStoryBranchName(123); + + expect(result.content[0].type).toBe("text"); + expect(result.content[0].text).toBe( + "Branch name for story sc-123: user1/sc-123/test-story-1", + ); + }); + + test("should generate a custom branch name if not included in api", async () => { + const storyTools = new StoryTools({ + ...mockClient, + getStory: mock(async () => ({ + ...mockStories[0], + formatted_vcs_branch_name: null, + name: "Story 1", + })), + } as unknown as ShortcutClientWrapper); + + const result = await storyTools.getStoryBranchName(123); + + expect(result.content[0].type).toBe("text"); + expect(result.content[0].text).toBe("Branch name for story sc-123: testuser/sc-123/story-1"); + }); + + test("should truncate long branch names when building custom branch name", async () => { + const storyTools = new StoryTools({ + ...mockClient, + getStory: mock(async () => ({ + ...mockStories[0], + formatted_vcs_branch_name: null, + name: "This is a very long story name that will be truncated in the branch name because it exceeds the maximum length allowed for branch names", + })), + } as unknown as ShortcutClientWrapper); + + const result = await storyTools.getStoryBranchName(123); + + expect(result.content[0].type).toBe("text"); + expect(result.content[0].text).toBe( + "Branch name for story sc-123: testuser/sc-123/this-is-a-very-long-story-name-tha", + ); + }); + + test("should handle special characters in story name when building custom branch name", async () => { + const storyTools = new StoryTools({ + ...mockClient, + getStory: mock(async () => ({ + ...mockStories[0], + formatted_vcs_branch_name: null, + name: "Special characters: !@#$%^&*()_+{}[]|\\:;\"'<>,.?/", + })), + } as unknown as ShortcutClientWrapper); + + const result = await storyTools.getStoryBranchName(123); + + expect(result.content[0].type).toBe("text"); + expect(result.content[0].text).toBe( + "Branch name for story sc-123: testuser/sc-123/special-characters-_", + ); + }); + }); + + describe("createStoryComment method", () => { + const createStoryCommentMock = mock(async (_: CreateStoryCommentParams) => ({ + id: 1000, + text: "Added comment to story sc-123.", + })); + const getStoryMock = mock(async (id: number) => mockStories.find((story) => story.id === id)); + + const mockClient = { + getStory: getStoryMock, + createStoryComment: createStoryCommentMock, + } as unknown as ShortcutClientWrapper; + + beforeEach(() => { + createStoryCommentMock.mockClear(); + }); + + test("should create a story comment", async () => { + const storyTools = new StoryTools(mockClient); + const result = await storyTools.createStoryComment({ + storyPublicId: 123, + text: "Added comment to story sc-123.", + }); + + expect(result.content[0].text).toBe( + `Created comment on story sc-123. Comment URL: ${mockStories[0].comments[0].app_url}.`, + ); + expect(createStoryCommentMock).toHaveBeenCalledTimes(1); + }); + + test("should throw error if comment is not specified", async () => { + const storyTools = new StoryTools(mockClient); + + await expect(() => + storyTools.createStoryComment({ + storyPublicId: 123, + text: "", + }), + ).toThrow("Story comment text is required"); + }); + + test("should throw error if story ID is not found", async () => { + const storyTools = new StoryTools({ + ...mockClient, + createStoryComment: mock(async () => null), + } as unknown as ShortcutClientWrapper); + + await expect(() => + storyTools.createStoryComment({ + storyPublicId: 124, + text: "This is a new comment", + }), + ).toThrow("Failed to retrieve Shortcut story with public ID: 124"); + }); + }); +}); diff --git a/shortcut-mcp-tools/src/tools/stories.ts b/shortcut-mcp-tools/src/tools/stories.ts new file mode 100644 index 0000000..216dde3 --- /dev/null +++ b/shortcut-mcp-tools/src/tools/stories.ts @@ -0,0 +1,382 @@ +import type { ShortcutClientWrapper } from "@/client/shortcut"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { MemberInfo, Story } from "@shortcut/client"; +import { z } from "zod"; +import { BaseTools } from "./base"; +import { + formatAsUnorderedList, + formatMemberList, + formatPullRequestList, + formatStoryList, + formatTaskList, +} from "./utils/format"; +import { type QueryParams, buildSearchQuery } from "./utils/search"; +import { date, has, is, user } from "./utils/validation"; + +export class StoryTools extends BaseTools { + static create(client: ShortcutClientWrapper, server: McpServer) { + const tools = new StoryTools(client); + + server.tool( + "get-story-branch-name", + "Get a valid branch name for a specific story.", + { + storyPublicId: z.number().positive().describe("The public Id of the story"), + }, + async ({ storyPublicId }) => await tools.getStoryBranchName(storyPublicId), + ); + + server.tool( + "get-story", + "Get a Shortcut story by public ID", + { + storyPublicId: z.number().positive().describe("The public ID of the story to get"), + }, + async ({ storyPublicId }) => await tools.getStory(storyPublicId), + ); + + server.tool( + "search-stories", + "Find Shortcut stories.", + { + id: z.number().optional().describe("Find only stories with the specified public ID"), + name: z.string().optional().describe("Find only stories matching the specified name"), + description: z + .string() + .optional() + .describe("Find only stories matching the specified description"), + comment: z.string().optional().describe("Find only stories matching the specified comment"), + type: z + .enum(["feature", "bug", "chore"]) + .optional() + .describe("Find only stories of the specified type"), + estimate: z + .number() + .optional() + .describe("Find only stories matching the specified estimate"), + branch: z.string().optional().describe("Find only stories matching the specified branch"), + commit: z.string().optional().describe("Find only stories matching the specified commit"), + pr: z.number().optional().describe("Find only stories matching the specified pull request"), + project: z.number().optional().describe("Find only stories matching the specified project"), + epic: z.number().optional().describe("Find only stories matching the specified epic"), + objective: z + .number() + .optional() + .describe("Find only stories matching the specified objective"), + state: z.string().optional().describe("Find only stories matching the specified state"), + label: z.string().optional().describe("Find only stories matching the specified label"), + owner: user("owner"), + requester: user("requester"), + team: z + .string() + .optional() + .describe( + "Find only stories matching the specified team. This can be a team mention name or team name.", + ), + skillSet: z + .string() + .optional() + .describe("Find only stories matching the specified skill set"), + productArea: z + .string() + .optional() + .describe("Find only stories matching the specified product area"), + technicalArea: z + .string() + .optional() + .describe("Find only stories matching the specified technical area"), + priority: z + .string() + .optional() + .describe("Find only stories matching the specified priority"), + severity: z + .string() + .optional() + .describe("Find only stories matching the specified severity"), + isDone: is("completed"), + isStarted: is("started"), + isUnstarted: is("unstarted"), + isUnestimated: is("unestimated"), + isOverdue: is("overdue"), + isArchived: is("archived").default(false), + isBlocker: is("blocking"), + isBlocked: is("blocked"), + hasComment: has("a comment"), + hasLabel: has("a label"), + hasDeadline: has("a deadline"), + hasOwner: has("an owner"), + hasPr: has("a pr"), + hasCommit: has("a commit"), + hasBranch: has("a branch"), + hasEpic: has("an epic"), + hasTask: has("a task"), + hasAttachment: has("an attachment"), + created: date, + updated: date, + completed: date, + due: date, + }, + async (params) => await tools.searchStories(params), + ); + + server.tool( + "create-story", + `Create a new Shortcut story. +Name is required, and either a Team or Workflow must be specified: +- If only Team is specified, we will use the default workflow for that team. +- If Workflow is specified, it will be used regardless of Team. +The story will be added to the default state for the workflow. +`, + { + name: z.string().min(1).max(512).describe("The name of the story. Required."), + description: z.string().max(10_000).optional().describe("The description of the story"), + type: z + .enum(["feature", "bug", "chore"]) + .default("feature") + .describe("The type of the story"), + owner: z.string().optional().describe("The user id of the owner of the story"), + epic: z.number().optional().describe("The epic id of the epic the story belongs to"), + team: z + .string() + .optional() + .describe( + "The team ID or mention name of the team the story belongs to. Required unless a workflow is specified.", + ), + workflow: z + .number() + .optional() + .describe("The workflow ID to add the story to. Required unless a team is specified."), + }, + async ({ name, description, type, owner, epic, team, workflow }) => + await tools.createStory({ + name, + description, + type, + owner, + epic, + team, + workflow, + }), + ); + + server.tool( + "assign-current-user-as-owner", + "Assign the current user as the owner of a story", + { + storyPublicId: z.number().positive().describe("The public ID of the story"), + }, + async ({ storyPublicId }) => await tools.assignCurrentUserAsOwner(storyPublicId), + ); + + server.tool( + "unassign-current-user-as-owner", + "Unassign the current user as the owner of a story", + { + storyPublicId: z.number().positive().describe("The public ID of the story"), + }, + async ({ storyPublicId }) => await tools.unassignCurrentUserAsOwner(storyPublicId), + ); + + server.tool( + "create-story-comment", + "Create a comment on a story", + { + storyPublicId: z.number().positive().describe("The public ID of the story"), + text: z.string().min(1).describe("The text of the comment"), + }, + async (params) => await tools.createStoryComment(params), + ); + + return tools; + } + + async assignCurrentUserAsOwner(storyPublicId: number) { + const story = await this.client.getStory(storyPublicId); + + if (!story) + throw new Error(`Failed to retrieve Shortcut story with public ID: ${storyPublicId}`); + + const currentUser = await this.client.getCurrentUser(); + + if (!currentUser) throw new Error("Failed to retrieve current user"); + + if (story.owner_ids.includes(currentUser.id)) + return this.toResult(`Current user is already an owner of story sc-${storyPublicId}`); + + await this.client.updateStory(storyPublicId, { + owner_ids: story.owner_ids.concat([currentUser.id]), + }); + + return this.toResult(`Assigned current user as owner of story sc-${storyPublicId}`); + } + + async unassignCurrentUserAsOwner(storyPublicId: number) { + const story = await this.client.getStory(storyPublicId); + + if (!story) + throw new Error(`Failed to retrieve Shortcut story with public ID: ${storyPublicId}`); + + const currentUser = await this.client.getCurrentUser(); + + if (!currentUser) throw new Error("Failed to retrieve current user"); + + if (!story.owner_ids.includes(currentUser.id)) + return this.toResult(`Current user is not an owner of story sc-${storyPublicId}`); + + await this.client.updateStory(storyPublicId, { + owner_ids: story.owner_ids.filter((ownerId) => ownerId !== currentUser.id), + }); + + return this.toResult(`Unassigned current user as owner of story sc-${storyPublicId}`); + } + + private createBranchName(currentUser: MemberInfo, story: Story) { + return `${currentUser.mention_name}/sc-${story.id}/${story.name + .toLowerCase() + .replace(/\s+/g, "-") + .replace(/[^\w\-]/g, "")}`.substring(0, 50); + } + + async getStoryBranchName(storyPublicId: number) { + const currentUser = await this.client.getCurrentUser(); + if (!currentUser) throw new Error("Unable to find current user"); + + const story = await this.client.getStory(storyPublicId); + + if (!story) + throw new Error(`Failed to retrieve Shortcut story with public ID: ${storyPublicId}`); + + const branchName = + (story as Story & { formatted_vcs_branch_name: string | null }).formatted_vcs_branch_name || + this.createBranchName(currentUser, story); + return this.toResult(`Branch name for story sc-${storyPublicId}: ${branchName}`); + } + + async createStory({ + name, + description, + type, + owner, + epic, + team, + workflow, + }: { + name: string; + description?: string; + type: "feature" | "bug" | "chore"; + owner?: string; + epic?: number; + team?: string; + workflow?: number; + }) { + if (!workflow && !team) throw new Error("Team or Workflow has to be specified"); + + if (!workflow && team) { + const fullTeam = await this.client.getTeam(team); + workflow = fullTeam?.workflow_ids?.[0]; + } + + if (!workflow) throw new Error("Failed to find workflow for team"); + + const fullWorkflow = await this.client.getWorkflow(workflow); + if (!fullWorkflow) throw new Error("Failed to find workflow"); + + const story = await this.client.createStory({ + name, + description, + story_type: type, + owner_ids: owner ? [owner] : [], + epic_id: epic, + group_id: team, + workflow_state_id: fullWorkflow.default_state_id, + }); + + return this.toResult(`Created story: ${story.id}`); + } + + async searchStories(params: QueryParams) { + const currentUser = await this.client.getCurrentUser(); + const query = await buildSearchQuery(params, currentUser); + const { stories, total } = await this.client.searchStories(query); + + if (!stories) throw new Error(`Failed to search for stories matching your query: "${query}".`); + if (!stories.length) return this.toResult(`Result: No stories found.`); + + const users = await this.client.getUserMap(stories.flatMap((story) => story.owner_ids)); + + return this.toResult(`Result (first ${stories.length} shown of ${total} total stories found): +${formatStoryList(stories, users)}`); + } + + async getStory(storyPublicId: number) { + const story = await this.client.getStory(storyPublicId); + + if (!story) + throw new Error(`Failed to retrieve Shortcut story with public ID: ${storyPublicId}.`); + + const relatedUsers = new Set([ + ...story.owner_ids, + ...story.comments.flatMap((c) => c.author_id), + ]); + const users = await this.client.getUserMap( + [...relatedUsers].filter((id): id is string => !!id), + ); + + return this.toResult(`Story: sc-${storyPublicId} +URL: ${story.app_url} +Name: ${story.name} +Type: ${story.story_type} +Archived: ${story.archived ? "Yes" : "No"} +Completed: ${story.completed ? "Yes" : "No"} +Started: ${story.started ? "Yes" : "No"} +Blocked: ${story.blocked ? "Yes" : "No"} +Blocking: ${story.blocker ? "Yes" : "No"} +Due date: ${story.deadline ? story.deadline : "(none)"} +Team: ${story.group_id ? `${story.group_id}` : "(none)"} +${formatMemberList(story.owner_ids, users, "Owners")} +Epic: ${story.epic_id ? `${story.epic_id}` : "(none)"} +Iteration: ${story.iteration_id ? `${story.iteration_id}` : "(none)"} + +Description: +${story.description} + +${formatAsUnorderedList(story.external_links, "External Links")} + +${formatPullRequestList(story.branches)} + +${formatTaskList(story.tasks)} + +Comments: +${(story.comments || []) + .map((comment) => { + const mentionName = comment.author_id + ? users.get(comment.author_id)?.profile?.mention_name + : null; + return `- From: ${ + mentionName ? `@${mentionName}` : `id=${comment.author_id}` || "[Unknown]" + } on ${comment.created_at}.\n${comment.text || ""}`; + }) + .join("\n\n")}`); + } + + async createStoryComment({ + storyPublicId, + text, + }: { + storyPublicId: number; + text: string; + }) { + if (!storyPublicId) throw new Error("Story public ID is required"); + if (!text) throw new Error("Story comment text is required"); + + const story = await this.client.getStory(storyPublicId); + if (!story) + throw new Error(`Failed to retrieve Shortcut story with public ID: ${storyPublicId}`); + + const storyComment = await this.client.createStoryComment(storyPublicId, { text }); + + return this.toResult( + `Created comment on story sc-${storyPublicId}. Comment URL: ${storyComment.app_url}.`, + ); + } +} diff --git a/shortcut-mcp-tools/src/tools/teams.test.ts b/shortcut-mcp-tools/src/tools/teams.test.ts new file mode 100644 index 0000000..c9703a4 --- /dev/null +++ b/shortcut-mcp-tools/src/tools/teams.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, mock, spyOn, test } from "bun:test"; +import type { ShortcutClientWrapper } from "@/client/shortcut"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { Group, Member, Workflow } from "@shortcut/client"; +import { TeamTools } from "./teams"; + +describe("TeamTools", () => { + const mockMembers: Member[] = [ + { + id: "user1", + profile: { + mention_name: "john", + name: "John Doe", + email_address: "john@example.com", + }, + } as Member, + { + id: "user2", + profile: { + mention_name: "jane", + name: "Jane Smith", + email_address: "jane@example.com", + }, + } as Member, + ]; + + const mockTeams: Group[] = [ + { + id: "team1", + name: "Team 1", + mention_name: "team-one", + description: "Description for Team 1", + member_ids: ["user1", "user2"], + workflow_ids: [1, 2], + } as Group, + { + id: "team2", + name: "Team 2", + mention_name: "team-two", + description: "Description for Team 2", + member_ids: ["user1"], + workflow_ids: [1], + } as Group, + ]; + + const mockWorkflows: Workflow[] = [ + { + id: 1, + name: "Workflow 1", + description: "Description for Workflow 1", + default_state_id: 101, + states: [ + { id: 101, name: "Unstarted", type: "unstarted" }, + { id: 102, name: "Started", type: "started" }, + ], + } as Workflow, + { + id: 2, + name: "Workflow 2", + description: "Description for Workflow 2", + default_state_id: 201, + states: [ + { id: 201, name: "Backlog", type: "unstarted" }, + { id: 202, name: "In Progress", type: "started" }, + ], + } as Workflow, + ]; + + describe("create method", () => { + test("should register the correct tools with the server", () => { + const mockClient = {} as ShortcutClientWrapper; + const mockTool = mock(); + const mockServer = { tool: mockTool } as unknown as McpServer; + + TeamTools.create(mockClient, mockServer); + + expect(mockTool).toHaveBeenCalledTimes(2); + + expect(mockTool.mock.calls?.[0]?.[0]).toBe("get-team"); + expect(mockTool.mock.calls?.[1]?.[0]).toBe("list-teams"); + }); + + test("should call correct function from tool", async () => { + const mockClient = {} as ShortcutClientWrapper; + const mockTool = mock(); + const mockServer = { tool: mockTool } as unknown as McpServer; + + const tools = TeamTools.create(mockClient, mockServer); + + spyOn(tools, "getTeam").mockImplementation(async () => ({ + content: [{ text: "", type: "text" }], + })); + await mockTool.mock.calls?.[0]?.[3]({ teamPublicId: "team1" }); + expect(tools.getTeam).toHaveBeenCalledWith("team1"); + + spyOn(tools, "getTeams").mockImplementation(async () => ({ + content: [{ text: "", type: "text" }], + })); + await mockTool.mock.calls?.[1]?.[2](); + expect(tools.getTeams).toHaveBeenCalled(); + }); + }); + + describe("getTeam method", () => { + const getTeamMock = mock(async (id: string) => mockTeams.find((team) => team.id === id)); + const getUserMapMock = mock(async (ids: string[]) => { + const map = new Map(); + for (const id of ids) { + const member = mockMembers.find((m) => m.id === id); + if (member) map.set(id, member); + } + return map; + }); + + const mockClient = { + getTeam: getTeamMock, + getUserMap: getUserMapMock, + } as unknown as ShortcutClientWrapper; + + test("should return formatted team details when team is found", async () => { + const teamTools = new TeamTools(mockClient); + const result = await teamTools.getTeam("team1"); + + expect(result.content[0].type).toBe("text"); + expect(String(result.content[0].text).split("\n")).toMatchObject([ + "Id: team1", + "Name: Team 1", + "Mention name: team-one", + "Description: Description for Team 1", + "Members:", + "- id=user1 @john", + "- id=user2 @jane", + ]); + }); + + test("should handle team not found", async () => { + const teamTools = new TeamTools({ + getTeam: mock(async () => null), + } as unknown as ShortcutClientWrapper); + + const result = await teamTools.getTeam("nonexistent"); + + expect(result.content[0].type).toBe("text"); + expect(result.content[0].text).toBe("Team with public ID: nonexistent not found."); + }); + + test("should handle team with no members", async () => { + const teamTools = new TeamTools({ + getTeam: mock(async () => ({ + ...mockTeams[0], + member_ids: [], + })), + getUserMap: getUserMapMock, + } as unknown as ShortcutClientWrapper); + + const result = await teamTools.getTeam("team1"); + + expect(result.content[0].type).toBe("text"); + expect(String(result.content[0].text).split("\n")).toMatchObject([ + "Id: team1", + "Name: Team 1", + "Mention name: team-one", + "Description: Description for Team 1", + "Members: (none)", + ]); + }); + }); + + describe("getTeams method", () => { + const getTeamsMock = mock(async () => mockTeams); + const getWorkflowMapMock = mock(async (ids: number[]) => { + const map = new Map(); + for (const id of ids) { + const workflow = mockWorkflows.find((w) => w.id === id); + if (workflow) map.set(id, workflow); + } + return map; + }); + + const mockClient = { + getTeams: getTeamsMock, + getWorkflowMap: getWorkflowMapMock, + } as unknown as ShortcutClientWrapper; + + test("should return formatted list of teams when teams are found", async () => { + const teamTools = new TeamTools(mockClient); + const result = await teamTools.getTeams(); + + expect(result.content[0].type).toBe("text"); + expect(String(result.content[0].text).split("\n")).toMatchObject([ + "Result (first 2 shown of 2 total teams found):", + "", + "Id: team1", + "Name: Team 1", + "Description: Description for Team 1", + "Number of Members: 2", + "Workflows:", + "- id=1 name=Workflow 1. Default state: id=101 name=Unstarted", + "- id=2 name=Workflow 2. Default state: id=201 name=Backlog", + "", + "Id: team2", + "Name: Team 2", + "Description: Description for Team 2", + "Number of Members: 1", + "Workflows:", + "- id=1 name=Workflow 1. Default state: id=101 name=Unstarted", + ]); + }); + + test("should return no teams found message when no teams exist", async () => { + const teamTools = new TeamTools({ + getTeams: mock(async () => []), + } as unknown as ShortcutClientWrapper); + + const result = await teamTools.getTeams(); + + expect(result.content[0].type).toBe("text"); + expect(result.content[0].text).toBe("No teams found."); + }); + + test("should handle team with no workflows", async () => { + const teamTools = new TeamTools({ + getTeams: mock(async () => [ + { + ...mockTeams[0], + workflow_ids: [], + }, + ]), + getWorkflowMap: getWorkflowMapMock, + } as unknown as ShortcutClientWrapper); + + const result = await teamTools.getTeams(); + + expect(result.content[0].type).toBe("text"); + expect(String(result.content[0].text).split("\n")).toMatchObject([ + "Result (first 1 shown of 1 total teams found):", + "", + "Id: team1", + "Name: Team 1", + "Description: Description for Team 1", + "Number of Members: 2", + "Workflows: (none)", + ]); + }); + }); +}); diff --git a/shortcut-mcp-tools/src/tools/teams.ts b/shortcut-mcp-tools/src/tools/teams.ts new file mode 100644 index 0000000..106a127 --- /dev/null +++ b/shortcut-mcp-tools/src/tools/teams.ts @@ -0,0 +1,56 @@ +import type { ShortcutClientWrapper } from "@/client/shortcut"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { BaseTools } from "./base"; +import { formatMemberList, formatWorkflowList } from "./utils/format"; + +export class TeamTools extends BaseTools { + static create(client: ShortcutClientWrapper, server: McpServer) { + const tools = new TeamTools(client); + + server.tool( + "get-team", + "Get a Shortcut team by public ID", + { teamPublicId: z.string().describe("The public ID of the team to get") }, + async ({ teamPublicId }) => await tools.getTeam(teamPublicId), + ); + + server.tool("list-teams", "List all Shortcut teams", async () => await tools.getTeams()); + + return tools; + } + + async getTeam(teamPublicId: string) { + const team = await this.client.getTeam(teamPublicId); + + if (!team) return this.toResult(`Team with public ID: ${teamPublicId} not found.`); + + const users = await this.client.getUserMap(team.member_ids); + + return this.toResult(`Id: ${team.id} +Name: ${team.name} +Mention name: ${team.mention_name} +Description: ${team.description} +${formatMemberList(team.member_ids, users)}`); + } + + async getTeams() { + const teams = await this.client.getTeams(); + + if (!teams.length) return this.toResult(`No teams found.`); + + const workflows = await this.client.getWorkflowMap(teams.flatMap((team) => team.workflow_ids)); + + return this.toResult(`Result (first ${teams.length} shown of ${teams.length} total teams found): + +${teams + .map( + (team) => `Id: ${team.id} +Name: ${team.name} +Description: ${team.description} +Number of Members: ${team.member_ids.length} +${formatWorkflowList(team.workflow_ids, workflows)}`, + ) + .join("\n\n")}`); + } +} diff --git a/shortcut-mcp-tools/src/tools/user.test.ts b/shortcut-mcp-tools/src/tools/user.test.ts new file mode 100644 index 0000000..cd7af49 --- /dev/null +++ b/shortcut-mcp-tools/src/tools/user.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, mock, spyOn, test } from "bun:test"; +import type { ShortcutClientWrapper } from "@/client/shortcut"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { UserTools } from "./user"; + +describe("UserTools", () => { + const mockCurrentUser = { + id: "user1", + mention_name: "testuser", + name: "Test User", + }; + + describe("create method", () => { + test("should register the correct tools with the server", () => { + const mockClient = {} as ShortcutClientWrapper; + const mockTool = mock(); + const mockServer = { tool: mockTool } as unknown as McpServer; + + UserTools.create(mockClient, mockServer); + + expect(mockTool).toHaveBeenCalledTimes(1); + expect(mockTool.mock.calls?.[0]?.[0]).toBe("get-current-user"); + }); + + test("should call correct function from tool", async () => { + const mockClient = {} as ShortcutClientWrapper; + const mockTool = mock(); + const mockServer = { tool: mockTool } as unknown as McpServer; + + const tools = UserTools.create(mockClient, mockServer); + + spyOn(tools, "getCurrentUser").mockImplementation(async () => ({ + content: [{ text: "", type: "text" }], + })); + await mockTool.mock.calls?.[0]?.[2](); + expect(tools.getCurrentUser).toHaveBeenCalled(); + }); + }); + + describe("getCurrentUser method", () => { + const getCurrentUserMock = mock(async () => mockCurrentUser); + const mockClient = { getCurrentUser: getCurrentUserMock } as unknown as ShortcutClientWrapper; + + test("should return formatted current user details", async () => { + const userTools = new UserTools(mockClient); + const result = await userTools.getCurrentUser(); + + expect(result.content[0].type).toBe("text"); + expect(String(result.content[0].text).split("\n")).toMatchObject([ + "Current user:", + "Id: user1", + "Mention name: @testuser", + "Full name: Test User", + ]); + }); + + test("should throw error when current user is not found", async () => { + const userTools = new UserTools({ + getCurrentUser: mock(async () => null), + } as unknown as ShortcutClientWrapper); + + await expect(() => userTools.getCurrentUser()).toThrow("Failed to retrieve current user."); + }); + + test("should propagate errors from client", async () => { + const userTools = new UserTools({ + getCurrentUser: mock(async () => { + throw new Error("API error"); + }), + } as unknown as ShortcutClientWrapper); + + await expect(() => userTools.getCurrentUser()).toThrow("API error"); + }); + }); +}); diff --git a/shortcut-mcp-tools/src/tools/user.ts b/shortcut-mcp-tools/src/tools/user.ts new file mode 100644 index 0000000..19cda71 --- /dev/null +++ b/shortcut-mcp-tools/src/tools/user.ts @@ -0,0 +1,30 @@ +import type { ShortcutClientWrapper } from "@/client/shortcut"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { BaseTools } from "./base"; + +export class UserTools extends BaseTools { + static create(client: ShortcutClientWrapper, server: McpServer) { + const tools = new UserTools(client); + + server.tool( + "get-current-user", + "Get the current user", + async () => await tools.getCurrentUser(), + ); + + return tools; + } + + async getCurrentUser() { + const user = await this.client.getCurrentUser(); + + if (!user) throw new Error("Failed to retrieve current user."); + + return this.toResult( + `Current user: +Id: ${user.id} +Mention name: @${user.mention_name} +Full name: ${user.name}`, + ); + } +} diff --git a/shortcut-mcp-tools/src/tools/utils/format.test.ts b/shortcut-mcp-tools/src/tools/utils/format.test.ts new file mode 100644 index 0000000..dbe3cb4 --- /dev/null +++ b/shortcut-mcp-tools/src/tools/utils/format.test.ts @@ -0,0 +1,400 @@ +import { describe, expect, test } from "bun:test"; +import type { + Branch, + IterationStats, + Member, + PullRequest, + Story, + Task, + Workflow, +} from "@shortcut/client"; +import { + formatAsUnorderedList, + formatMemberList, + formatPullRequestList, + formatStats, + formatStoryList, + formatTaskList, + formatWorkflowList, +} from "./format"; + +// Mock data for tests +const mockMembers: Member[] = [ + { + id: "user1", + profile: { + mention_name: "john", + name: "John Doe", + email_address: "john@example.com", + }, + } as Member, + { + id: "user2", + profile: { + mention_name: "jane", + name: "Jane Smith", + email_address: "jane@example.com", + }, + } as Member, +]; + +const mockUsers = new Map(mockMembers.map((member) => [member.id, member])); + +interface MockStoryParams { + id: number; + name: string; + owner_ids?: string[]; + completed?: boolean; + started?: boolean; + story_type?: string; + epic_id?: number | null; + iteration_id?: number | null; + group_id?: string | null; +} + +const createMockStory = ({ + id, + name, + owner_ids = [], + completed = false, + started = false, + story_type = "feature", + epic_id = null, + iteration_id = null, + group_id = null, +}: MockStoryParams): Story => + ({ + id, + name, + owner_ids, + completed, + started, + story_type, + epic_id, + iteration_id, + group_id, + }) as Story; + +const mockWorkflowStates = [ + { id: 500, name: "Unstarted" }, + { id: 501, name: "Started" }, + { id: 502, name: "Completed" }, +]; + +const mockWorkflows: Workflow[] = [ + { + id: 1, + name: "Workflow 1", + default_state_id: 500, + states: mockWorkflowStates, + } as Workflow, + { + id: 2, + name: "Workflow 2", + default_state_id: 501, + states: mockWorkflowStates, + } as Workflow, + { + id: 3, + name: "Workflow 3", + default_state_id: 999, // Non-existent state + states: mockWorkflowStates, + } as Workflow, +]; + +const mockWorkflowsMap = new Map( + mockWorkflows.map((workflow) => [workflow.id, workflow]), +); + +const mockBranches = [ + { + id: 1, + name: "branch1", + pull_requests: [ + { + id: 1, + title: "Test PR 1", + url: "https://github.com/user1/repo1/pull/1", + merged: true, + } as PullRequest, + { + id: 2, + title: "Test PR 2", + url: "https://github.com/user1/repo1/pull/2", + merged: false, + } as PullRequest, + ], + } as Branch, +]; + +const mockTasks = [ + { description: "task 1", complete: false }, + { description: "task 2", complete: true }, +] satisfies Partial[] as Task[]; + +describe("formatAsUnorderedList", () => { + test("should format an empty list without label", () => { + const result = formatAsUnorderedList([]); + expect(result).toBe("(none)"); + }); + + test("should format an empty list with label", () => { + const result = formatAsUnorderedList([], "Label"); + expect(result).toBe("Label: (none)"); + }); + + test("should format a list without label", () => { + const result = formatAsUnorderedList(["item1", "item2"]); + expect(result).toBe("- item1\n- item2"); + }); + + test("should format a list with label", () => { + const result = formatAsUnorderedList(["item1", "item2"], "Label"); + expect(result).toBe("Label:\n- item1\n- item2"); + }); +}); + +describe("formatStoryList", () => { + test("should return empty string for empty stories array", () => { + const result = formatStoryList([], mockUsers); + expect(result).toBe("(none)"); + }); + + test("should format a story with team, epic, and iteration", () => { + const stories = [ + createMockStory({ + id: 123, + name: "Test Story", + epic_id: 1, + iteration_id: 2, + group_id: "group1", + }), + ]; + const result = formatStoryList(stories, mockUsers); + expect(result).toBe( + "- sc-123: Test Story (Type: feature, State: Not Started, Team: group1, Epic: 1, Iteration: 2, Owners: (none))", + ); + }); + + test("should format a single story with no owners", () => { + const stories = [createMockStory({ id: 123, name: "Test Story" })]; + const result = formatStoryList(stories, mockUsers); + expect(result).toBe( + "- sc-123: Test Story (Type: feature, State: Not Started, Team: (none), Epic: (none), Iteration: (none), Owners: (none))", + ); + }); + + test("should format a single story with one owner", () => { + const stories = [createMockStory({ id: 123, name: "Test Story", owner_ids: ["user1"] })]; + const result = formatStoryList(stories, mockUsers); + expect(result).toBe( + "- sc-123: Test Story (Type: feature, State: Not Started, Team: (none), Epic: (none), Iteration: (none), Owners: @john)", + ); + }); + + test("should format a single story with multiple owners", () => { + const stories = [ + createMockStory({ + id: 123, + name: "Test Story", + owner_ids: ["user1", "user2"], + }), + ]; + const result = formatStoryList(stories, mockUsers); + expect(result).toBe( + "- sc-123: Test Story (Type: feature, State: Not Started, Team: (none), Epic: (none), Iteration: (none), Owners: @john, @jane)", + ); + }); + + test("should format multiple stories with various states", () => { + const stories = [ + createMockStory({ id: 123, name: "Unstarted Story" }), + createMockStory({ id: 124, name: "Started Story", started: true }), + createMockStory({ id: 125, name: "Completed Story", completed: true }), + ]; + const result = formatStoryList(stories, mockUsers); + expect(result).toBe( + "- sc-123: Unstarted Story (Type: feature, State: Not Started, Team: (none), Epic: (none), Iteration: (none), Owners: (none))\n" + + "- sc-124: Started Story (Type: feature, State: In Progress, Team: (none), Epic: (none), Iteration: (none), Owners: (none))\n" + + "- sc-125: Completed Story (Type: feature, State: Completed, Team: (none), Epic: (none), Iteration: (none), Owners: (none))", + ); + }); + + test("should handle owners that do not exist in the users map", () => { + // Create a custom users map that returns null for non-existent users + // This matches the behavior expected by the filter in formatStoryList + const customUsers = new Map( + mockMembers.map((member) => [member.id, member]), + ); + // Set the non-existent user to null explicitly + customUsers.set("nonexistent", null); + + const stories = [ + createMockStory({ + id: 123, + name: "Test Story", + owner_ids: ["user1", "nonexistent"], + }), + ]; + const result = formatStoryList(stories, customUsers as Map); + + expect(result).toBe( + "- sc-123: Test Story (Type: feature, State: Not Started, Team: (none), Epic: (none), Iteration: (none), Owners: @john)", + ); + }); +}); + +describe("formatMemberList", () => { + test("should return empty string for empty ids array", () => { + const result = formatMemberList([], mockUsers); + expect(result).toBe("Members: (none)"); + }); + + test("should format a single member", () => { + const result = formatMemberList(["user1"], mockUsers); + expect(result).toBe("Members:\n- id=user1 @john"); + }); + + test("should format multiple members", () => { + const result = formatMemberList(["user1", "user2"], mockUsers); + expect(result).toBe("Members:\n- id=user1 @john\n- id=user2 @jane"); + }); + + test("should not filter out non-existent members", () => { + const result = formatMemberList(["nonexistent"], mockUsers); + expect(result).toBe("Members:\n- id=nonexistent [Unknown]"); + }); + + test("should handle a mix of existing and non-existing members", () => { + const result = formatMemberList(["user1", "nonexistent", "user2"], mockUsers); + expect(result).toBe("Members:\n- id=user1 @john\n- id=nonexistent [Unknown]\n- id=user2 @jane"); + }); +}); + +describe("formatWorkflowList", () => { + test("should return empty string for empty ids array", () => { + const result = formatWorkflowList([], mockWorkflowsMap); + expect(result).toBe("Workflows: (none)"); + }); + + test("should format a single workflow", () => { + const result = formatWorkflowList([1], mockWorkflowsMap); + expect(result).toBe("Workflows:\n- id=1 name=Workflow 1. Default state: id=500 name=Unstarted"); + }); + + test("should format multiple workflows", () => { + const result = formatWorkflowList([1, 2], mockWorkflowsMap); + expect(result).toBe( + "Workflows:\n- id=1 name=Workflow 1. Default state: id=500 name=Unstarted\n- id=2 name=Workflow 2. Default state: id=501 name=Started", + ); + }); + + test("should filter out non-existent workflows", () => { + const result = formatWorkflowList([999], mockWorkflowsMap); + expect(result).toBe("Workflows: (none)"); + }); + + test("should handle a workflow with unknown default state", () => { + const result = formatWorkflowList([3], mockWorkflowsMap); + expect(result).toBe("Workflows:\n- id=3 name=Workflow 3. Default state: [Unknown]"); + }); + + test("should handle a mix of existing and non-existing workflows", () => { + const result = formatWorkflowList([1, 999, 2], mockWorkflowsMap); + expect(result).toBe( + "Workflows:\n- id=1 name=Workflow 1. Default state: id=500 name=Unstarted\n- id=2 name=Workflow 2. Default state: id=501 name=Started", + ); + }); +}); + +describe("formatPullRequestList", () => { + test("should return empty string for empty branches array", () => { + const result = formatPullRequestList([]); + expect(result).toBe("Pull Requests: (none)"); + }); + + test("should return empty string for branch without pull requests", () => { + const result = formatPullRequestList([{ id: 1, name: "branch1" } as Branch]); + expect(result).toBe("Pull Requests: (none)"); + }); + + test("should format a single pull request", () => { + const result = formatPullRequestList([ + { + id: 1, + name: "branch1", + pull_requests: [ + { + id: 1, + title: "Test PR 1", + url: "https://github.com/user1/repo1/pull/1", + merged: true, + } as PullRequest, + ], + } as Branch, + ]); + expect(result).toBe( + "Pull Requests:\n- Title: Test PR 1, Merged: Yes, URL: https://github.com/user1/repo1/pull/1", + ); + }); + + test("should format multiple pull requests", () => { + const result = formatPullRequestList(mockBranches); + expect(result).toBe( + [ + "Pull Requests:", + "- Title: Test PR 1, Merged: Yes, URL: https://github.com/user1/repo1/pull/1", + "- Title: Test PR 2, Merged: No, URL: https://github.com/user1/repo1/pull/2", + ].join("\n"), + ); + }); +}); + +describe("formatTaskList", () => { + test("should format task lists", () => { + const result = formatTaskList(mockTasks); + expect(result).toBe("Tasks:\n- [ ] task 1\n- [X] task 2"); + }); +}); + +describe("formatStats", () => { + test("should format stats with points", () => { + const result = formatStats( + { + num_stories_backlog: 1, + num_stories_unstarted: 2, + num_stories_started: 3, + num_stories_done: 4, + num_points_backlog: 10, + num_points_unstarted: 20, + num_points_started: 30, + num_points_done: 40, + num_stories_unestimated: 1, + } as IterationStats, + true, + ); + expect(result).toBe( + "Stats:\n- Total stories: 10 (100 points)\n- Unstarted stories: 3 (30 points)\n- Stories in progress: 3 (30 points)\n- Completed stories: 4 (40 points)\n- (1 of the stories are unestimated)", + ); + }); + + test("should format stats without points", () => { + const result = formatStats( + { + num_stories_backlog: 1, + num_stories_unstarted: 2, + num_stories_started: 3, + num_stories_done: 4, + num_points_backlog: 10, + num_points_unstarted: 20, + num_points_started: 30, + num_points_done: 40, + num_stories_unestimated: 1, + } as IterationStats, + false, + ); + expect(result).toBe( + "Stats:\n- Total stories: 10\n- Unstarted stories: 3\n- Stories in progress: 3\n- Completed stories: 4", + ); + }); +}); diff --git a/shortcut-mcp-tools/src/tools/utils/format.ts b/shortcut-mcp-tools/src/tools/utils/format.ts new file mode 100644 index 0000000..ca9ef18 --- /dev/null +++ b/shortcut-mcp-tools/src/tools/utils/format.ts @@ -0,0 +1,213 @@ +import type { + Branch, + EpicStats, + IterationStats, + Member, + Story, + StorySearchResult, + Task, + Workflow, +} from "@shortcut/client"; + +type PropInclude = Record>; + +export type FormatOptions = { + /** Properties to include (if undefined, include all) */ + include?: string[] | PropInclude; + /** How deep to go into nested structures (default: unlimited) */ + depth?: number; + /** Format for indentation (internal use) */ + indent?: string; +}; + +export function jsonToText(data: unknown, options: FormatOptions = {}): string { + const indent = options.indent || ""; + + if (data === null || data === undefined) return ""; + if (Array.isArray(data)) return formatArray(data, { ...options, indent }); + if (typeof data === "object") + return formatObject(data as Record, { ...options, indent }); + return formatPrimitive(data); +} + +function formatPrimitive(value: unknown): string { + if (typeof value === "boolean") return value ? "Yes" : "No"; + return String(value); +} + +function formatArray(arr: Array, options: FormatOptions = {}): string { + if (arr.length === 0) return "(empty)"; + + const indent = options.indent || ""; + const nextIndent = `${indent} `; + + return arr + .map((item) => { + let formattedItem: string; + + if (typeof item === "object" && item !== null) { + formattedItem = jsonToText(item, { + ...options, + indent: nextIndent, + depth: options.depth !== undefined ? options.depth - 1 : undefined, + }); + + if (formattedItem.includes("\n")) return `${indent}- \n${formattedItem}`; + } else formattedItem = formatPrimitive(item); + + return `${indent}- ${formattedItem}`; + }) + .join("\n"); +} + +function formatObject(obj: Record, options: FormatOptions = {}): string { + const indent = options.indent || ""; + const nextIndent = `${indent} `; + + if (options.depth !== undefined && options.depth <= 0) return `${indent}[Object]`; + if (Object.keys(obj).length === 0) return `${indent}(empty)`; + + let keys: string[]; + + if (!options.include) { + keys = Object.keys(obj); + } else if (Array.isArray(options.include)) { + const arr = options.include as string[]; + keys = Object.keys(obj).filter((key) => arr.includes(key)); + } else { + keys = Object.keys(obj).filter((key) => { + const include = options.include as Record; + return key in include; + }); + } + + return keys + .map((key) => { + const value = obj[key]; + const formattedKey = formatKey(key); + + let nestedInclude: FormatOptions["include"]; + if (options.include && !Array.isArray(options.include)) { + const includeValue = (options.include as Record)[key]; + if (includeValue === true) nestedInclude = undefined; + else nestedInclude = includeValue; + } + + const formattedValue = jsonToText(value, { + ...options, + include: nestedInclude, + indent: nextIndent, + depth: options.depth !== undefined ? options.depth - 1 : undefined, + }); + + if (!formattedValue.includes("\n")) { + return `${indent}${formattedKey}: ${formattedValue}`; + } + + return `${indent}${formattedKey}:\n${formattedValue}`; + }) + .join("\n"); +} + +function formatKey(key: string): string { + return key + .replace(/([A-Z])/g, " $1") // Insert space before capitals + .replace(/_/g, " ") // Replace underscores with spaces + .trim() + .split(" ") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(" "); +} + +export const formatAsUnorderedList = (items: string[], label?: string) => { + return `${label ? `${label}:` : ""}${items?.length ? `${label ? "\n" : ""}${formatArray(items)}` : `${label ? " " : ""}(none)`}`; +}; + +export const formatStoryList = ( + stories: (Story | StorySearchResult)[], + users: Map, + label?: string, +) => { + return formatAsUnorderedList( + stories.map( + (story) => + `sc-${story.id}: ${story.name} (Type: ${story.story_type}, State: ${story.completed ? "Completed" : story.started ? "In Progress" : "Not Started"}, Team: ${story.group_id ? `${story.group_id}` : "(none)"}, Epic: ${story.epic_id ? `${story.epic_id}` : "(none)"}, Iteration: ${story.iteration_id ? `${story.iteration_id}` : "(none)"}, Owners: ${ + story.owner_ids + .map((ownerId) => users.get(ownerId)) + .filter((owner): owner is Member => owner !== null) + .map((owner) => `@${owner.profile.mention_name}`) + .join(", ") || "(none)" + })`, + ), + label, + ); +}; + +export const formatMemberList = (ids: string[], users: Map, label = "Members") => { + return formatAsUnorderedList( + (ids || []).map((id) => { + const user = users.get(id); + return user ? `id=${user.id} @${user.profile.mention_name}` : `id=${id} [Unknown]`; + }), + label, + ); +}; + +export const formatWorkflowList = (ids: number[], workflows: Map) => { + return formatAsUnorderedList( + (ids || []) + .map((id) => workflows.get(id)) + .filter((workflow): workflow is Workflow => !!workflow) + .map((workflow) => { + const defaultState = workflow.states.find( + (state) => state.id === workflow.default_state_id, + ); + return `id=${workflow.id} name=${workflow.name}. Default state: ${defaultState ? `id=${defaultState.id} name=${defaultState.name}` : "[Unknown]"}`; + }), + "Workflows", + ); +}; + +export const formatPullRequestList = (branches: Branch[]) => { + return formatAsUnorderedList( + (branches || []) + .flatMap((branch) => branch.pull_requests || []) + .map((pr) => `Title: ${pr.title}, Merged: ${pr.merged ? "Yes" : "No"}, URL: ${pr.url}`), + "Pull Requests", + ); +}; + +export const formatTaskList = (tasks: Task[]) => { + return formatAsUnorderedList( + (tasks || []).map((task) => `[${task.complete ? "X" : " "}] ${task.description}`), + "Tasks", + ); +}; + +export const formatStats = (stats: EpicStats | IterationStats, showPoints: boolean) => { + const { num_stories_backlog, num_stories_unstarted, num_stories_started, num_stories_done } = + stats; + const { num_points_backlog, num_points_unstarted, num_points_started, num_points_done } = stats; + + const totalCount = + num_stories_backlog + num_stories_unstarted + num_stories_started + num_stories_done; + const totalUnstarted = num_stories_backlog + num_stories_unstarted; + + const totalPoints = + (num_points_backlog || 0) + + (num_points_unstarted || 0) + + (num_points_started || 0) + + (num_points_done || 0); + const totalUnstartedPoints = (num_points_backlog || 0) + (num_points_unstarted || 0); + + const statsString = `Stats: +- Total stories: ${totalCount}${showPoints ? ` (${totalPoints} points)` : ""} +- Unstarted stories: ${totalUnstarted}${showPoints ? ` (${totalUnstartedPoints} points)` : ""} +- Stories in progress: ${num_stories_started}${showPoints ? ` (${num_points_started || 0} points)` : ""} +- Completed stories: ${num_stories_done}${showPoints ? ` (${num_points_done || 0} points)` : ""}`; + + if (showPoints && stats.num_stories_unestimated) + return `${statsString}\n- (${stats.num_stories_unestimated} of the stories are unestimated)`; + + return statsString; +}; diff --git a/shortcut-mcp-tools/src/tools/utils/search.test.ts b/shortcut-mcp-tools/src/tools/utils/search.test.ts new file mode 100644 index 0000000..a6ace0f --- /dev/null +++ b/shortcut-mcp-tools/src/tools/utils/search.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, test } from "bun:test"; +import type { MemberInfo } from "@shortcut/client"; +import { type QueryParams, buildSearchQuery } from "./search"; + +describe("buildSearchQuery", () => { + // Mock current user for testing + const mockCurrentUser: MemberInfo = { + id: "user1", + mention_name: "johndoe", + name: "John Doe", + } as MemberInfo; + + test("should return empty string for empty params", async () => { + const result = await buildSearchQuery({}, null); + expect(result).toBe(""); + }); + + test("should format boolean parameters correctly", async () => { + const params: QueryParams = { + isStarted: true, + isDone: false, + }; + const result = await buildSearchQuery(params, null); + expect(result).toBe("is:started !is:done"); + }); + + test("should format has parameters correctly", async () => { + const params: QueryParams = { + hasOwner: true, + hasLabel: false, + }; + const result = await buildSearchQuery(params, null); + expect(result).toBe("has:owner !has:label"); + }); + + test("should format number parameters correctly", async () => { + const params: QueryParams = { + id: 123, + estimate: 5, + }; + const result = await buildSearchQuery(params, null); + expect(result).toBe("id:123 estimate:5"); + }); + + test("should format string parameters correctly", async () => { + const params: QueryParams = { + name: "test", + state: "started", + }; + const result = await buildSearchQuery(params, null); + expect(result).toBe("name:test state:started"); + }); + + test("should quote string parameters with spaces", async () => { + const params: QueryParams = { + name: "test story", + description: "some description", + }; + const result = await buildSearchQuery(params, null); + expect(result).toBe('name:"test story" description:"some description"'); + }); + + test("should handle mixed parameter types correctly", async () => { + const params: QueryParams = { + id: 123, + name: "test", + isStarted: true, + hasOwner: false, + }; + const result = await buildSearchQuery(params, null); + expect(result).toBe("id:123 name:test is:started !has:owner"); + }); + + test('should replace "me" with current user mention name for owner parameter', async () => { + const params: QueryParams = { + owner: "me", + }; + const result = await buildSearchQuery(params, mockCurrentUser); + expect(result).toBe("owner:johndoe"); + }); + + test('should replace "me" with current user mention name for requester parameter', async () => { + const params: QueryParams = { + requester: "me", + }; + const result = await buildSearchQuery(params, mockCurrentUser); + expect(result).toBe("requester:johndoe"); + }); + + test('should keep "me" if current user is null', async () => { + const params: QueryParams = { + owner: "me", + }; + const result = await buildSearchQuery(params, null); + expect(result).toBe("owner:me"); + }); + + test("should handle other user names for owner parameter", async () => { + const params: QueryParams = { + owner: "janedoe", + }; + const result = await buildSearchQuery(params, mockCurrentUser); + expect(result).toBe("owner:janedoe"); + }); +}); diff --git a/shortcut-mcp-tools/src/tools/utils/search.ts b/shortcut-mcp-tools/src/tools/utils/search.ts new file mode 100644 index 0000000..578b8e9 --- /dev/null +++ b/shortcut-mcp-tools/src/tools/utils/search.ts @@ -0,0 +1,25 @@ +import type { MemberInfo } from "@shortcut/client"; + +const getKey = (prop: string) => { + if (prop.startsWith("is")) return `is:${prop.slice(2).toLowerCase()}`; + if (prop.startsWith("has")) return `has:${prop.slice(3).toLowerCase()}`; + return prop; +}; + +export type QueryParams = { [key: string]: boolean | string | number }; + +export const buildSearchQuery = async (params: QueryParams, currentUser: MemberInfo | null) => { + const query = Object.entries(params) + .map(([key, value]) => { + const q = getKey(key); + if ((key === "owner" || key === "requester") && value === "me") + return `${q}:${currentUser?.mention_name || value}`; + if (typeof value === "boolean") return value ? q : `!${q}`; + if (typeof value === "number") return `${q}:${value}`; + if (typeof value === "string" && value.includes(" ")) return `${q}:"${value}"`; + return `${q}:${value}`; + }) + .join(" "); + + return query; +}; diff --git a/shortcut-mcp-tools/src/tools/utils/validation.test.ts b/shortcut-mcp-tools/src/tools/utils/validation.test.ts new file mode 100644 index 0000000..afd6a79 --- /dev/null +++ b/shortcut-mcp-tools/src/tools/utils/validation.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, test } from "bun:test"; +import { z } from "zod"; +import { date, has, is, user } from "./validation"; + +describe("validation utilities", () => { + describe("date validation", () => { + test("should validate valid date formats", () => { + const validDates = [ + "2023-01-01", + "today", + "yesterday", + "tomorrow", + "2023-01-01..*", + "*..2023-01-01", + "today..*", + "*..today", + "yesterday..*", + "*..yesterday", + "tomorrow..*", + "*..tomorrow", + ]; + + for (const dateStr of validDates) { + const result = date.safeParse(dateStr); + expect(result.success).toBe(true); + } + }); + + test("should reject invalid date formats", () => { + const invalidDates = [ + "01-01-2023", // wrong format + "not-a-date", + "today-1", // invalid relative date + "tomorrow+1", // invalid relative date + "2023-01-01..", // incomplete range + "..2023-01-01", // incomplete range + "today..tomorrow", // closed range with keywords is not allowed + ]; + + for (const dateStr of invalidDates) { + const result = date.safeParse(dateStr); + expect(result.success).toBe(false); + } + }); + + test("should allow undefined values", () => { + const result = date.safeParse(undefined); + expect(result.success).toBe(true); + }); + }); + + describe("is validation", () => { + test("should create a boolean validator with description", () => { + const isStarted = is("started"); + + // Validate that it's a Zod schema + expect(isStarted instanceof z.ZodType).toBe(true); + + // Validate boolean values + expect(isStarted.safeParse(true).success).toBe(true); + expect(isStarted.safeParse(false).success).toBe(true); + + // Validate undefined is allowed + expect(isStarted.safeParse(undefined).success).toBe(true); + + // Validate non-boolean values are rejected + expect(isStarted.safeParse("true").success).toBe(false); + expect(isStarted.safeParse(1).success).toBe(false); + }); + }); + + describe("has validation", () => { + test("should create a boolean validator with description", () => { + const hasOwner = has("owner"); + + // Validate that it's a Zod schema + expect(hasOwner instanceof z.ZodType).toBe(true); + + // Validate boolean values + expect(hasOwner.safeParse(true).success).toBe(true); + expect(hasOwner.safeParse(false).success).toBe(true); + + // Validate undefined is allowed + expect(hasOwner.safeParse(undefined).success).toBe(true); + + // Validate non-boolean values are rejected + expect(hasOwner.safeParse("true").success).toBe(false); + expect(hasOwner.safeParse(1).success).toBe(false); + }); + }); + + describe("user validation", () => { + test("should create a string validator with description", () => { + const ownerUser = user("owner"); + + // Validate that it's a Zod schema + expect(ownerUser instanceof z.ZodType).toBe(true); + + // Validate string values + expect(ownerUser.safeParse("johndoe").success).toBe(true); + expect(ownerUser.safeParse("me").success).toBe(true); + + // Validate undefined is allowed + expect(ownerUser.safeParse(undefined).success).toBe(true); + + // Validate non-string values are rejected + expect(ownerUser.safeParse(true).success).toBe(false); + expect(ownerUser.safeParse(123).success).toBe(false); + }); + }); +}); diff --git a/shortcut-mcp-tools/src/tools/utils/validation.ts b/shortcut-mcp-tools/src/tools/utils/validation.ts new file mode 100644 index 0000000..925e539 --- /dev/null +++ b/shortcut-mcp-tools/src/tools/utils/validation.ts @@ -0,0 +1,58 @@ +import { z } from "zod"; + +const dateformat = "\\d{4}-\\d{2}-\\d{2}"; +const range = ({ + f = "\\*", + t = "\\*", +}: { f?: never; t?: string } | { f?: string; t?: never } | { f?: string; t?: string }) => + `${f}\\.\\.${t}`; + +const variations = [ + "today", + "tomorrow", + "yesterday", + dateformat, + range({ f: "today" }), + range({ t: "today" }), + range({ f: "yesterday" }), + range({ t: "yesterday" }), + range({ f: "tomorrow" }), + range({ t: "tomorrow" }), + range({ f: dateformat }), + range({ t: dateformat }), + range({ f: dateformat, t: dateformat }), +]; + +const DATE_REGEXP = new RegExp(`^(${variations.join("|")})$`); + +export const date = z + .string() + .regex(DATE_REGEXP) + .optional() + .describe( + 'The date in "YYYY-MM-DD" format, or one of the keywords: "yesterday", "today", "tomorrow", or a date range in the format "YYYY-MM-DD..YYYY-MM-DD". The date range can also be open ended by using "*" for one of the bounds. Examples: "2023-01-01", "today", "2023-01-01..*" (from Jan 1, 2023 to any future date), "*.2023-01-31" (any date up to Jan 31, 2023), "today..*" (from today onwards), "*.yesterday" (any date up to yesterday). The keywords cannot be used to calculate relative dates (e.g. the following are not valid: "today-1" or "tomorrow+1").', + ); + +export const is = (field: string) => + z + .boolean() + .optional() + .describe( + `Find only entities that are ${field} when true, or only entities that are not ${field} when false.`, + ); + +export const has = (field: string) => + z + .boolean() + .optional() + .describe( + `Find only entities that have ${field} when true, or only entities that do not have ${field} when false. Example: hasOwner: true will find stories with an owner, hasOwner: false will find stories without an owner.`, + ); + +export const user = (field: string) => + z + .string() + .optional() + .describe( + `Find entities where the ${field} match the specified user. This must either be the user\'s mention name or the keyword "me" for the current user.`, + ); diff --git a/shortcut-mcp-tools/src/tools/workflows.test.ts b/shortcut-mcp-tools/src/tools/workflows.test.ts new file mode 100644 index 0000000..5689058 --- /dev/null +++ b/shortcut-mcp-tools/src/tools/workflows.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, mock, spyOn, test } from "bun:test"; +import type { ShortcutClientWrapper } from "@/client/shortcut"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { Workflow } from "@shortcut/client"; +import { WorkflowTools } from "./workflows"; + +describe("WorkflowTools", () => { + const mockWorkflows: Workflow[] = [ + { + id: 1, + name: "Workflow 1", + description: "Description for Workflow 1", + default_state_id: 101, + states: [ + { id: 101, name: "Unstarted", type: "unstarted" }, + { id: 102, name: "Started", type: "started" }, + { id: 103, name: "Done", type: "done" }, + ], + } as Workflow, + { + id: 2, + name: "Workflow 2", + description: "Description for Workflow 2", + default_state_id: 201, + states: [ + { id: 201, name: "Backlog", type: "unstarted" }, + { id: 202, name: "In Progress", type: "started" }, + { id: 203, name: "Completed", type: "done" }, + ], + } as Workflow, + ]; + + describe("create method", () => { + test("should register the correct tools with the server", () => { + const mockClient = {} as ShortcutClientWrapper; + const mockTool = mock(); + const mockServer = { tool: mockTool } as unknown as McpServer; + + WorkflowTools.create(mockClient, mockServer); + + expect(mockTool).toHaveBeenCalledTimes(2); + + expect(mockTool.mock.calls?.[0]?.[0]).toBe("get-workflow"); + expect(mockTool.mock.calls?.[1]?.[0]).toBe("list-workflows"); + }); + + test("should call correct function from tool", async () => { + const mockClient = {} as ShortcutClientWrapper; + const mockTool = mock(); + const mockServer = { tool: mockTool } as unknown as McpServer; + + const tools = WorkflowTools.create(mockClient, mockServer); + + spyOn(tools, "getWorkflow").mockImplementation(async () => ({ + content: [{ text: "", type: "text" }], + })); + await mockTool.mock.calls?.[0]?.[3]({ workflowPublicId: 1 }); + expect(tools.getWorkflow).toHaveBeenCalledWith(1); + + spyOn(tools, "listWorkflows").mockImplementation(async () => ({ + content: [{ text: "", type: "text" }], + })); + await mockTool.mock.calls?.[1]?.[2](); + expect(tools.listWorkflows).toHaveBeenCalled(); + }); + }); + + describe("getWorkflow method", () => { + const getWorkflowMock = mock(async (id: number) => + mockWorkflows.find((workflow) => workflow.id === id), + ); + const mockClient = { getWorkflow: getWorkflowMock } as unknown as ShortcutClientWrapper; + + test("should return formatted workflow details when workflow is found", async () => { + const workflowTools = new WorkflowTools(mockClient); + const result = await workflowTools.getWorkflow(1); + + expect(result.content[0].type).toBe("text"); + expect(String(result.content[0].text).split("\n")).toMatchObject([ + "Id: 1", + "Name: Workflow 1", + "Description: Description for Workflow 1", + "States:", + "- id=101 name=Unstarted (default: yes, type: unstarted)", + "- id=102 name=Started (default: no, type: started)", + "- id=103 name=Done (default: no, type: done)", + ]); + }); + + test("should handle workflow not found", async () => { + const workflowTools = new WorkflowTools({ + getWorkflow: mock(async () => null), + } as unknown as ShortcutClientWrapper); + + const result = await workflowTools.getWorkflow(999); + + expect(result.content[0].type).toBe("text"); + expect(result.content[0].text).toBe("Workflow with public ID: 999 not found."); + }); + }); + + describe("listWorkflows method", () => { + const getWorkflowsMock = mock(async () => mockWorkflows); + const mockClient = { getWorkflows: getWorkflowsMock } as unknown as ShortcutClientWrapper; + + test("should return formatted list of workflows when workflows are found", async () => { + const workflowTools = new WorkflowTools(mockClient); + const result = await workflowTools.listWorkflows(); + + expect(result.content[0].type).toBe("text"); + expect(String(result.content[0].text).split("\n")).toMatchObject([ + "Result (first 2 shown of 2 total workflows found):", + "", + "Id: 1", + "Name: Workflow 1", + "Description: Description for Workflow 1", + "Default State: Unstarted", + "", + "Id: 2", + "Name: Workflow 2", + "Description: Description for Workflow 2", + "Default State: Backlog", + ]); + }); + }); + + test("should return no workflows found message when no workflows exist", async () => { + const workflowTools = new WorkflowTools({ + getWorkflows: mock(async () => []), + } as unknown as ShortcutClientWrapper); + + const result = await workflowTools.listWorkflows(); + + expect(result.content[0].type).toBe("text"); + expect(result.content[0].text).toBe("No workflows found."); + }); + + test("should handle workflow with unknown default state", async () => { + const workflowTools = new WorkflowTools({ + getWorkflows: mock(async () => [ + { + id: 3, + name: "Workflow 3", + description: "Description for Workflow 3", + default_state_id: 999, // Non-existent state ID + states: [ + { id: 301, name: "Unstarted", type: "unstarted" }, + { id: 302, name: "Started", type: "started" }, + ], + } as Workflow, + ]), + } as unknown as ShortcutClientWrapper); + + const result = await workflowTools.listWorkflows(); + + expect(result.content[0].type).toBe("text"); + expect(String(result.content[0].text).split("\n")).toMatchObject([ + "Result (first 1 shown of 1 total workflows found):", + "", + "Id: 3", + "Name: Workflow 3", + "Description: Description for Workflow 3", + "Default State: [Unknown]", + ]); + }); +}); diff --git a/shortcut-mcp-tools/src/tools/workflows.ts b/shortcut-mcp-tools/src/tools/workflows.ts new file mode 100644 index 0000000..90985c2 --- /dev/null +++ b/shortcut-mcp-tools/src/tools/workflows.ts @@ -0,0 +1,55 @@ +import type { ShortcutClientWrapper } from "@/client/shortcut"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { BaseTools } from "./base"; +import { formatAsUnorderedList } from "./utils/format"; + +export class WorkflowTools extends BaseTools { + static create(client: ShortcutClientWrapper, server: McpServer) { + const tools = new WorkflowTools(client); + + server.tool( + "get-workflow", + "Get a Shortcut workflow by public ID", + { workflowPublicId: z.number().positive().describe("The public ID of the workflow to get") }, + async ({ workflowPublicId }) => await tools.getWorkflow(workflowPublicId), + ); + + server.tool( + "list-workflows", + "List all Shortcut workflows", + async () => await tools.listWorkflows(), + ); + + return tools; + } + + async getWorkflow(workflowPublicId: number) { + const workflow = await this.client.getWorkflow(workflowPublicId); + + if (!workflow) return this.toResult(`Workflow with public ID: ${workflowPublicId} not found.`); + + return this.toResult(`Id: ${workflow.id} +Name: ${workflow.name} +Description: ${workflow.description} +States: +${formatAsUnorderedList(workflow.states.map((state) => `id=${state.id} name=${state.name} (default: ${state.id === workflow.default_state_id ? "yes" : "no"}, type: ${state.type})`))}`); + } + + async listWorkflows() { + const workflows = await this.client.getWorkflows(); + + if (!workflows.length) return this.toResult(`No workflows found.`); + + return this.toResult(`Result (first ${workflows.length} shown of ${workflows.length} total workflows found): + +${workflows + .map( + (workflow) => `Id: ${workflow.id} +Name: ${workflow.name} +Description: ${workflow.description} +Default State: ${workflow.states.find((state) => state.id === workflow.default_state_id)?.name || "[Unknown]"}`, + ) + .join("\n\n")}`); + } +} diff --git a/shortcut-mcp-tools/tsconfig.json b/shortcut-mcp-tools/tsconfig.json new file mode 100644 index 0000000..efbe4ea --- /dev/null +++ b/shortcut-mcp-tools/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Node", + "esModuleInterop": true, + "declaration": true, + "outDir": "./dist", + "strict": true, + "skipLibCheck": true, + "sourceMap": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} \ No newline at end of file diff --git a/shortcut-mcp-worker/.dev.vars b/shortcut-mcp-worker/.dev.vars new file mode 100644 index 0000000..5998199 --- /dev/null +++ b/shortcut-mcp-worker/.dev.vars @@ -0,0 +1,3 @@ +# OAuth application credentials (register at https://app.shortcut.com/settings/api-applications) +SHORTCUT_CLIENT_ID="YOUR_CLIENT_ID" +SHORTCUT_CLIENT_SECRET="YOUR_CLIENT_SECRET" \ No newline at end of file diff --git a/shortcut-mcp-worker/README.md b/shortcut-mcp-worker/README.md new file mode 100644 index 0000000..8e28933 --- /dev/null +++ b/shortcut-mcp-worker/README.md @@ -0,0 +1,136 @@ +# Shortcut MCP Worker + +A Cloudflare Worker implementation of the Shortcut MCP (Model Context Protocol) Server, allowing AI assistants to interact with Shortcut's project management tools. + +## Features + +- Remote HTTP+SSE transport for MCP +- OAuth authentication with Shortcut +- Full support for all Shortcut MCP tools +- Easy deployment to Cloudflare's edge network + +## Setup + +### Prerequisites + +1. Node.js and npm installed +2. A Cloudflare account +3. wrangler CLI installed (`npm install -g wrangler`) +4. A Shortcut OAuth application (created at https://app.shortcut.com/settings/api-applications) + +### Installation + +1. Clone this repository +2. Install dependencies: + ```bash + npm install + ``` + +3. Configure your environment variables: + - Create or edit the `.dev.vars` file with your Shortcut OAuth credentials: + ``` + SHORTCUT_CLIENT_ID="your_client_id" + SHORTCUT_CLIENT_SECRET="your_client_secret" + ``` + +### Development + +Run the development server: + +```bash +npm run dev +``` + +This will start a local server at http://localhost:8787 where you can test your MCP server. + +### Deployment + +Deploy to Cloudflare Workers: + +```bash +npm run deploy +``` + +After deployment, configure your environment variables in the Cloudflare dashboard or using the wrangler CLI: + +```bash +wrangler secret put SHORTCUT_CLIENT_ID +wrangler secret put SHORTCUT_CLIENT_SECRET +``` + +### Testing + +The project includes a comprehensive test suite using Bun's built-in testing framework. Tests focus on: + +1. **HTTP+SSE Transport**: Verifying the transport layer correctly handles MCP protocol requests and responses +2. **Token Extraction**: Ensuring auth tokens are correctly extracted from various sources +3. **Worker Behavior**: Testing core worker functionality + +Run the tests with: + +```bash +# Run all tests +bun test + +# Run tests and watch for changes +bun test --watch + +# Run specific test files +bun test test/transport/http-sse.test.ts +``` + +## Client Configuration + +### Claude Code + +Add the following to your `~/.claude.json` file: + +```json +{ + "projects": { + "mcpServers": { + "shortcut": { + "mcpUrl": "https://your-worker-url.workers.dev/sse" + } + } + } +} +``` + +### Cursor + +Add the following to your `~/.cursor/mcp.json` or `/.cursor/mcp.json` file: + +```json +{ + "mcpServers": { + "shortcut": { + "mcpUrl": "https://your-worker-url.workers.dev/sse" + } + } +} +``` + +### Windsurf + +Add the following to your Windsurf MCP configuration: + +```json +{ + "mcpServers": { + "shortcut": { + "mcpUrl": "https://your-worker-url.workers.dev/sse" + } + } +} +``` + +## Troubleshooting + +- If you encounter authentication issues, ensure your OAuth credentials are correct +- Check Cloudflare Worker logs for any errors during execution +- Verify that your client configuration points to the correct worker URL + +## License + +MIT \ No newline at end of file diff --git a/shortcut-mcp-worker/TEST_PLAN.md b/shortcut-mcp-worker/TEST_PLAN.md new file mode 100644 index 0000000..a0d0592 --- /dev/null +++ b/shortcut-mcp-worker/TEST_PLAN.md @@ -0,0 +1,431 @@ +# Test Plan for Shortcut MCP Cloudflare Worker + +This document outlines the testing strategy for the Shortcut MCP Cloudflare Worker implementation. + +## Testing Goals + +1. Ensure the HTTP+SSE transport layer correctly implements the MCP protocol +2. Verify authentication flows work as expected +3. Confirm tools are properly integrated and accessible via the remote server +4. Test error handling and edge cases + +## Test Structure + +Tests will be organized into three main categories: + +1. **Unit Tests**: Testing individual components in isolation +2. **Integration Tests**: Testing interaction between components +3. **End-to-End Tests**: Testing the full workflow + +## Test Environment Setup + +```typescript +// test/setup.ts +import { beforeAll, afterAll, afterEach } from "bun:test"; +import { makeCloudflareEnv } from "./helpers/cloudflare-env"; + +// Create a mock environment for tests +let env: ReturnType; + +beforeAll(() => { + env = makeCloudflareEnv({ + SHORTCUT_CLIENT_ID: "test-client-id", + SHORTCUT_CLIENT_SECRET: "test-client-secret" + }); +}); + +afterEach(() => { + // Clean up any mocks or test data +}); + +afterAll(() => { + // Cleanup any resources +}); + +export { env }; +``` + +## Unit Tests + +### HTTP+SSE Transport Tests + +```typescript +// test/transport/http-sse.test.ts +import { describe, expect, test, mock, spyOn } from "bun:test"; +import { HttpSseTransport } from "../../src/transport/http-sse"; +import { env } from "../setup"; + +describe("HttpSseTransport", () => { + test("should initialize with a request", () => { + const request = new Request("https://example.com/sse", { + method: "POST", + body: JSON.stringify({ request_id: "123", action: "ping" }) + }); + + const transport = new HttpSseTransport(request, env); + expect(transport).toBeDefined(); + }); + + test("should receive MCP requests", async () => { + const mcpRequest = { request_id: "123", action: "ping" }; + const request = new Request("https://example.com/sse", { + method: "POST", + body: JSON.stringify(mcpRequest) + }); + + const transport = new HttpSseTransport(request, env); + const receivedRequest = await transport.receive(); + + expect(receivedRequest).toEqual(mcpRequest); + }); + + test("should send MCP responses", async () => { + const request = new Request("https://example.com/sse", { + method: "POST", + body: JSON.stringify({ request_id: "123", action: "ping" }) + }); + + const transport = new HttpSseTransport(request, env); + const writer = mock(() => ({ + write: mock(() => Promise.resolve()), + close: mock(() => Promise.resolve()) + })); + + // @ts-ignore - Mocking private property + transport.writer = writer(); + + await transport.send({ response_id: "123", action: "pong" }); + + expect(writer().write).toHaveBeenCalled(); + }); + + test("should format responses as server-sent events", async () => { + const request = new Request("https://example.com/sse", { + method: "POST", + body: JSON.stringify({ request_id: "123", action: "ping" }) + }); + + const transport = new HttpSseTransport(request, env); + const response = await transport.getResponse(); + + expect(response.headers.get("Content-Type")).toBe("text/event-stream"); + expect(response.headers.get("Cache-Control")).toBe("no-cache"); + expect(response.headers.get("Connection")).toBe("keep-alive"); + }); + + test("should close cleanly", async () => { + const request = new Request("https://example.com/sse", { + method: "POST", + body: JSON.stringify({ request_id: "123", action: "ping" }) + }); + + const transport = new HttpSseTransport(request, env); + const writer = mock(() => ({ + write: mock(() => Promise.resolve()), + close: mock(() => Promise.resolve()) + })); + + // @ts-ignore - Mocking private property + transport.writer = writer(); + + transport.close(); + + expect(writer().close).toHaveBeenCalled(); + }); +}); +``` + +### Authentication Tests + +```typescript +// test/auth.test.ts +import { describe, expect, test, mock, spyOn } from "bun:test"; +import { getTokenFromRequest, handleAuth } from "../src/auth"; +import { env } from "./setup"; + +describe("Authentication", () => { + test("should extract Bearer token from Authorization header", async () => { + const request = new Request("https://example.com/sse", { + headers: { + "Authorization": "Bearer test-token" + } + }); + + const token = await getTokenFromRequest(request, env); + expect(token).toBe("test-token"); + }); + + test("should extract token from cookies", async () => { + const request = new Request("https://example.com/sse", { + headers: { + "Cookie": "shortcut_token=cookie-token; other=value" + } + }); + + const token = await getTokenFromRequest(request, env); + expect(token).toBe("cookie-token"); + }); + + test("should handle OAuth authorization route", async () => { + const request = new Request("https://example.com/oauth/authorize"); + + // Mock the OAuthProvider + const mockProvider = { + handleAuthorize: mock(() => new Response()) + }; + + // Mock the createOAuthProvider function + const originalCreateProvider = require("../src/auth").createOAuthProvider; + require("../src/auth").createOAuthProvider = mock(() => mockProvider); + + try { + await handleAuth(request, env); + expect(mockProvider.handleAuthorize).toHaveBeenCalledWith(request); + } finally { + require("../src/auth").createOAuthProvider = originalCreateProvider; + } + }); + + test("should handle OAuth callback route", async () => { + const request = new Request("https://example.com/oauth/callback"); + + // Mock the OAuthProvider + const mockProvider = { + handleCallback: mock(() => new Response()) + }; + + // Mock the createOAuthProvider function + const originalCreateProvider = require("../src/auth").createOAuthProvider; + require("../src/auth").createOAuthProvider = mock(() => mockProvider); + + try { + await handleAuth(request, env); + expect(mockProvider.handleCallback).toHaveBeenCalledWith(request); + } finally { + require("../src/auth").createOAuthProvider = originalCreateProvider; + } + }); +}); +``` + +## Integration Tests + +### Worker Request Handler Tests + +```typescript +// test/index.test.ts +import { describe, expect, test, mock, spyOn } from "bun:test"; +import { default as worker } from "../src/index"; +import { env } from "./setup"; + +describe("Worker Request Handler", () => { + test("should handle OPTIONS requests with CORS headers", async () => { + const request = new Request("https://example.com/", { + method: "OPTIONS" + }); + + const response = await worker.fetch(request, env, { + waitUntil: mock(() => {}) + }); + + expect(response.status).toBe(204); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); + }); + + test("should delegate OAuth routes to auth handler", async () => { + const request = new Request("https://example.com/oauth/authorize"); + + // Mock the auth handler + const originalHandleAuth = require("../src/auth").handleAuth; + const mockHandleAuth = mock(() => new Response("Auth handled")); + require("../src/auth").handleAuth = mockHandleAuth; + + try { + const response = await worker.fetch(request, env, { + waitUntil: mock(() => {}) + }); + + expect(mockHandleAuth).toHaveBeenCalled(); + } finally { + require("../src/auth").handleAuth = originalHandleAuth; + } + }); + + test("should handle SSE routes with valid tokens", async () => { + const request = new Request("https://example.com/sse", { + method: "POST", + headers: { + "Authorization": "Bearer test-token" + }, + body: JSON.stringify({ request_id: "123", action: "ping" }) + }); + + // Mock token validation + const originalGetToken = require("../src/auth").getTokenFromRequest; + require("../src/auth").getTokenFromRequest = mock(() => "test-token"); + + // Mock HttpSseTransport + const originalTransport = require("../src/transport/http-sse").HttpSseTransport; + const mockTransport = mock(() => ({ + getResponse: mock(() => new Response("SSE response")), + // Other methods would be mocked as needed + })); + require("../src/transport/http-sse").HttpSseTransport = mockTransport; + + try { + const response = await worker.fetch(request, env, { + waitUntil: mock(() => {}) + }); + + expect(mockTransport).toHaveBeenCalled(); + expect(mockTransport().getResponse).toHaveBeenCalled(); + } finally { + require("../src/auth").getTokenFromRequest = originalGetToken; + require("../src/transport/http-sse").HttpSseTransport = originalTransport; + } + }); + + test("should return 401 for SSE routes without valid tokens", async () => { + const request = new Request("https://example.com/sse", { + method: "POST", + body: JSON.stringify({ request_id: "123", action: "ping" }) + }); + + // Mock token validation to return null + const originalGetToken = require("../src/auth").getTokenFromRequest; + require("../src/auth").getTokenFromRequest = mock(() => null); + + try { + const response = await worker.fetch(request, env, { + waitUntil: mock(() => {}) + }); + + expect(response.status).toBe(401); + } finally { + require("../src/auth").getTokenFromRequest = originalGetToken; + } + }); + + test("should return home page for root path", async () => { + const request = new Request("https://example.com/"); + + const response = await worker.fetch(request, env, { + waitUntil: mock(() => {}) + }); + + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toContain("text/html"); + + const text = await response.text(); + expect(text).toContain("Shortcut MCP Server"); + }); + + test("should return 404 for unknown routes", async () => { + const request = new Request("https://example.com/unknown-path"); + + const response = await worker.fetch(request, env, { + waitUntil: mock(() => {}) + }); + + expect(response.status).toBe(404); + }); +}); +``` + +## End-to-End Tests + +End-to-end tests would simulate a complete interaction with the MCP server, including: + +1. Authentication +2. Tool registration +3. Making MCP requests +4. Receiving MCP responses + +These tests could be implemented using a combination of: + +1. A test MCP client +2. Mocked Shortcut API responses +3. A local worker runtime environment + +## Mock Helpers + +```typescript +// test/helpers/cloudflare-env.ts +export function makeCloudflareEnv(vars = {}) { + return { + ...vars, + // Add any other environment bindings needed + }; +} + +// test/helpers/shortcut-mock.ts +export function mockShortcutClient() { + return { + getCurrentMemberInfo: mock(() => ({ + data: { + id: "user1", + mention_name: "testuser", + name: "Test User", + } + })), + // Add other methods as needed + }; +} +``` + +## Test Configuration + +Add the following to your package.json: + +```json +{ + "scripts": { + "test": "bun test", + "test:watch": "bun test --watch" + } +} +``` + +## Testing OAuth in Isolation + +Since OAuth is challenging to test in automated tests, consider creating a separate manual test suite or a dedicated test mode that bypasses OAuth for automated testing. + +## CI/CD Integration + +For GitHub Actions, create a workflow file: + +```yaml +# .github/workflows/test.yml +name: Test + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: oven-sh/setup-bun@v1 + - name: Install dependencies + run: bun install + - name: Run tests + run: bun test +``` + +## Test Coverage + +Add coverage reporting to your tests: + +```json +{ + "scripts": { + "test": "bun test --coverage" + } +} +``` + +This comprehensive test plan will help ensure that your Cloudflare Worker implementation is robust and reliable. \ No newline at end of file diff --git a/shortcut-mcp-worker/bun.lock b/shortcut-mcp-worker/bun.lock new file mode 100644 index 0000000..d41aa1c --- /dev/null +++ b/shortcut-mcp-worker/bun.lock @@ -0,0 +1,524 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "shortcut-mcp-worker", + "dependencies": { + "@cloudflare/workers-oauth-provider": "^0.0.5", + "@modelcontextprotocol/sdk": "^1.6.1", + "@shortcut/client": "^1.1.0", + "@shortcut/mcp-tools": "file:../shortcut-mcp-tools", + "zod": "^3.24.2", + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20240515.0", + "typescript": "^5.0.0", + "vite": "^5.0.0", + "wrangler": "^3.0.0", + }, + }, + }, + "packages": { + "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.3.4", "", { "dependencies": { "mime": "^3.0.0" } }, "sha512-YLPHc8yASwjNkmcDMQMY35yiWjoKAKnhUbPRszBRS0YgH+IXtsMp61j+yTcnCE3oO2DgP0U3iejLC8FTtKDC8Q=="], + + "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.0.2", "", { "peerDependencies": { "unenv": "2.0.0-rc.14", "workerd": "^1.20250124.0" }, "optionalPeers": ["workerd"] }, "sha512-nyzYnlZjjV5xT3LizahG1Iu6mnrCaxglJ04rZLpDwlDVDZ7v46lNsfxhV3A/xtfgQuSHmLnc6SVI+KwBpc3Lwg=="], + + "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20250408.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-bxhIwBWxaNItZLXDNOKY2dCv0FHjDiDkfJFpwv4HvtvU5MKcrivZHVmmfDzLW85rqzfcDOmKbZeMPVfiKxdBZw=="], + + "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20250408.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5XZ2Oykr8bSo7zBmERtHh18h5BZYC/6H1YFWVxEj3PtalF3+6SHsO4KZsbGvDml9Pu7sHV277jiZE5eny8Hlyw=="], + + "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20250408.0", "", { "os": "linux", "cpu": "x64" }, "sha512-WbgItXWln6G5d7GvYLWcuOzAVwafysZaWunH3UEfsm95wPuRofpYnlDD861gdWJX10IHSVgMStGESUcs7FLerQ=="], + + "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20250408.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-pAhEywPPvr92SLylnQfZEPgXz+9pOG9G9haAPLpEatncZwYiYd9yiR6HYWhKp2erzCoNrOqKg9IlQwU3z1IDiw=="], + + "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20250408.0", "", { "os": "win32", "cpu": "x64" }, "sha512-nJ3RjMKGae2aF2rZ/CNeBvQPM+W5V1SUK0FYWG/uomyr7uQ2l4IayHna1ODg/OHHTEgIjwom0Mbn58iXb0WOcQ=="], + + "@cloudflare/workers-oauth-provider": ["@cloudflare/workers-oauth-provider@0.0.5", "", { "dependencies": { "@cloudflare/workers-types": "^4.20250311.0" } }, "sha512-t1x5KAzsubCvb4APnJ93z407X1x7SGj/ga5ziRnwIb/iLy4PMkT/hgd1y5z7Bbsdy5Fy6mywhCP4lym24bX66w=="], + + "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20250508.0", "", {}, "sha512-Gr7NLsHy5BFXbWVMMO+1mf/DwxT30tNw5LGhC86S+CXErM2a2eJ0HJHqgAs0Y8Lt/XEUSrH9QrUFDvJWNhE4Rg=="], + + "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.4.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ=="], + + "@esbuild-plugins/node-globals-polyfill": ["@esbuild-plugins/node-globals-polyfill@0.2.3", "", { "peerDependencies": { "esbuild": "*" } }, "sha512-r3MIryXDeXDOZh7ih1l/yE9ZLORCd5e8vWg02azWRGj5SPTuoh69A2AIyn0Z31V/kHBfZ4HgWJ+OK3GTTwLmnw=="], + + "@esbuild-plugins/node-modules-polyfill": ["@esbuild-plugins/node-modules-polyfill@0.2.2", "", { "dependencies": { "escape-string-regexp": "^4.0.0", "rollup-plugin-node-polyfills": "^0.2.1" }, "peerDependencies": { "esbuild": "*" } }, "sha512-LXV7QsWJxRuMYvKbiznh+U1ilIop3g2TeKRzUxOG5X3YITc8JyyTa90BmLwqqv0YnX4v32CSlG+vsziZp9dMvA=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="], + + "@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.0.5", "", { "os": "linux", "cpu": "arm" }, "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.0.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.0.5" }, "os": "linux", "cpu": "arm" }, "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.0.4" }, "os": "linux", "cpu": "s390x" }, "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.33.5", "", { "dependencies": { "@emnapi/runtime": "^1.2.0" }, "cpu": "none" }, "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.33.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], + + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.11.0", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.3", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-k/1pb70eD638anoi0e8wUGAlbMJXyvdV4p62Ko+EZ7eBe1xMx8Uhak1R5DgfoofsK5IBBnRwsYGTaLZl+6/+RQ=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.40.2", "", { "os": "android", "cpu": "arm" }, "sha512-JkdNEq+DFxZfUwxvB58tHMHBHVgX23ew41g1OQinthJ+ryhdRk67O31S7sYw8u2lTjHUPFxwar07BBt1KHp/hg=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.40.2", "", { "os": "android", "cpu": "arm64" }, "sha512-13unNoZ8NzUmnndhPTkWPWbX3vtHodYmy+I9kuLxN+F+l+x3LdVF7UCu8TWVMt1POHLh6oDHhnOA04n8oJZhBw=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.40.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Gzf1Hn2Aoe8VZzevHostPX23U7N5+4D36WJNHK88NZHCJr7aVMG4fadqkIf72eqVPGjGc0HJHNuUaUcxiR+N/w=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.40.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-47N4hxa01a4x6XnJoskMKTS8XZ0CZMd8YTbINbi+w03A2w4j1RTlnGHOz/P0+Bg1LaVL6ufZyNprSg+fW5nYQQ=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.40.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-8t6aL4MD+rXSHHZUR1z19+9OFJ2rl1wGKvckN47XFRVO+QL/dUSpKA2SLRo4vMg7ELA8pzGpC+W9OEd1Z/ZqoQ=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.40.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-C+AyHBzfpsOEYRFjztcYUFsH4S7UsE9cDtHCtma5BK8+ydOZYgMmWg1d/4KBytQspJCld8ZIujFMAdKG1xyr4Q=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.40.2", "", { "os": "linux", "cpu": "arm" }, "sha512-de6TFZYIvJwRNjmW3+gaXiZ2DaWL5D5yGmSYzkdzjBDS3W+B9JQ48oZEsmMvemqjtAFzE16DIBLqd6IQQRuG9Q=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.40.2", "", { "os": "linux", "cpu": "arm" }, "sha512-urjaEZubdIkacKc930hUDOfQPysezKla/O9qV+O89enqsqUmQm8Xj8O/vh0gHg4LYfv7Y7UsE3QjzLQzDYN1qg=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.40.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-KlE8IC0HFOC33taNt1zR8qNlBYHj31qGT1UqWqtvR/+NuCVhfufAq9fxO8BMFC22Wu0rxOwGVWxtCMvZVLmhQg=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.40.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-j8CgxvfM0kbnhu4XgjnCWJQyyBOeBI1Zq91Z850aUddUmPeQvuAy6OiMdPS46gNFgy8gN1xkYyLgwLYZG3rBOg=="], + + "@rollup/rollup-linux-loongarch64-gnu": ["@rollup/rollup-linux-loongarch64-gnu@4.40.2", "", { "os": "linux", "cpu": "none" }, "sha512-Ybc/1qUampKuRF4tQXc7G7QY9YRyeVSykfK36Y5Qc5dmrIxwFhrOzqaVTNoZygqZ1ZieSWTibfFhQ5qK8jpWxw=="], + + "@rollup/rollup-linux-powerpc64le-gnu": ["@rollup/rollup-linux-powerpc64le-gnu@4.40.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-3FCIrnrt03CCsZqSYAOW/k9n625pjpuMzVfeI+ZBUSDT3MVIFDSPfSUgIl9FqUftxcUXInvFah79hE1c9abD+Q=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.40.2", "", { "os": "linux", "cpu": "none" }, "sha512-QNU7BFHEvHMp2ESSY3SozIkBPaPBDTsfVNGx3Xhv+TdvWXFGOSH2NJvhD1zKAT6AyuuErJgbdvaJhYVhVqrWTg=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.40.2", "", { "os": "linux", "cpu": "none" }, "sha512-5W6vNYkhgfh7URiXTO1E9a0cy4fSgfE4+Hl5agb/U1sa0kjOLMLC1wObxwKxecE17j0URxuTrYZZME4/VH57Hg=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.40.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-B7LKIz+0+p348JoAL4X/YxGx9zOx3sR+o6Hj15Y3aaApNfAshK8+mWZEf759DXfRLeL2vg5LYJBB7DdcleYCoQ=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.40.2", "", { "os": "linux", "cpu": "x64" }, "sha512-lG7Xa+BmBNwpjmVUbmyKxdQJ3Q6whHjMjzQplOs5Z+Gj7mxPtWakGHqzMqNER68G67kmCX9qX57aRsW5V0VOng=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.40.2", "", { "os": "linux", "cpu": "x64" }, "sha512-tD46wKHd+KJvsmije4bUskNuvWKFcTOIM9tZ/RrmIvcXnbi0YK/cKS9FzFtAm7Oxi2EhV5N2OpfFB348vSQRXA=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.40.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Bjv/HG8RRWLNkXwQQemdsWw4Mg+IJ29LK+bJPW2SCzPKOUaMmPEppQlu/Fqk1d7+DX3V7JbFdbkh/NMmurT6Pg=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.40.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-dt1llVSGEsGKvzeIO76HToiYPNPYPkmjhMHhP00T9S4rDern8P2ZWvWAQUEJ+R1UdMWJ/42i/QqJ2WV765GZcA=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.40.2", "", { "os": "win32", "cpu": "x64" }, "sha512-bwspbWB04XJpeElvsp+DCylKfF4trJDa2Y9Go8O6A7YLX2LIKGcNK/CYImJN6ZP4DcuOHB4Utl3iCbnR62DudA=="], + + "@shortcut/client": ["@shortcut/client@1.1.0", "", { "dependencies": { "axios": "^1.5.0" } }, "sha512-GLpfo4mQId4e32kju85v9HI7q2dVhiJnBcsnRH9IaZLd4JPgf9tETSOXFaQLzHC+pyY730ogsKDepU9yhkts2g=="], + + "@shortcut/mcp-tools": ["@shortcut/mcp-tools@file:../shortcut-mcp-tools", { "devDependencies": { "@modelcontextprotocol/sdk": "^1.6.1", "@shortcut/client": "^1.1.0", "typescript": "^5.0.0", "zod": "^3.24.2" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.6.1", "@shortcut/client": "^1.1.0", "zod": "^3.24.2" } }], + + "@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="], + + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], + + "acorn-walk": ["acorn-walk@8.3.2", "", {}, "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A=="], + + "as-table": ["as-table@1.0.55", "", { "dependencies": { "printable-characters": "^1.0.42" } }, "sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ=="], + + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + + "axios": ["axios@1.9.0", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } }, "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg=="], + + "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], + + "body-parser": ["body-parser@2.2.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.0", "http-errors": "^2.0.0", "iconv-lite": "^0.6.3", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.0", "type-is": "^2.0.0" } }, "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg=="], + + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], + + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + + "content-disposition": ["content-disposition@1.0.0", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "cors": ["cors@2.8.5", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "data-uri-to-buffer": ["data-uri-to-buffer@2.0.2", "", {}, "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA=="], + + "debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], + + "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], + + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "detect-libc": ["detect-libc@2.0.4", "", {}, "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + + "esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "estree-walker": ["estree-walker@0.6.1", "", {}, "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "eventsource": ["eventsource@3.0.6", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-l19WpE2m9hSuyP06+FbuUUf1G+R0SFLrtQfbRb9PRr+oimOfxQhgGCbVaXg5IvZyyTThJsxh6L/srkMiCeBPDA=="], + + "eventsource-parser": ["eventsource-parser@3.0.1", "", {}, "sha512-VARTJ9CYeuQYb0pZEPbzi740OWFgpHe7AYJ2WFZVnUDUQp5Dk2yJUgF36YsZ81cOyxT0QxmXD2EQpapAouzWVA=="], + + "exit-hook": ["exit-hook@2.2.1", "", {}, "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw=="], + + "express": ["express@5.1.0", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA=="], + + "express-rate-limit": ["express-rate-limit@7.5.0", "", { "peerDependencies": { "express": "^4.11 || 5 || ^5.0.0-beta.1" } }, "sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg=="], + + "exsolve": ["exsolve@1.0.5", "", {}, "sha512-pz5dvkYYKQ1AHVrgOzBKWeP4u4FRb3a6DNK2ucr0OoNwYIU4QWsJ+NM36LLzORT+z845MzKHHhpXiUF5nvQoJg=="], + + "finalhandler": ["finalhandler@2.1.0", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q=="], + + "follow-redirects": ["follow-redirects@1.15.9", "", {}, "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ=="], + + "form-data": ["form-data@4.0.2", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "mime-types": "^2.1.12" } }, "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "get-source": ["get-source@2.0.12", "", { "dependencies": { "data-uri-to-buffer": "^2.0.0", "source-map": "^0.6.1" } }, "sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w=="], + + "glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="], + + "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + + "is-arrayish": ["is-arrayish@0.3.2", "", {}, "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="], + + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "magic-string": ["magic-string@0.25.9", "", { "dependencies": { "sourcemap-codec": "^1.4.8" } }, "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.1", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA=="], + + "miniflare": ["miniflare@3.20250408.1", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "acorn": "8.14.0", "acorn-walk": "8.3.2", "exit-hook": "2.2.1", "glob-to-regexp": "0.4.1", "stoppable": "1.1.0", "undici": "^5.28.5", "workerd": "1.20250408.0", "ws": "8.18.0", "youch": "3.3.4", "zod": "3.22.3" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-0EHBNcd1yZSdTC/7GSe/kI1ge4+YuO/6QbEXDLtnVgUExKiMS5brfWsza1+Ps0/WCywKkER08lJwu6tFh7kF7g=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "mustache": ["mustache@4.2.0", "", { "bin": { "mustache": "bin/mustache" } }, "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], + + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "pkce-challenge": ["pkce-challenge@5.0.0", "", {}, "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ=="], + + "postcss": ["postcss@8.5.3", "", { "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A=="], + + "printable-characters": ["printable-characters@1.0.42", "", {}, "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ=="], + + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + + "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], + + "qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="], + + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + + "raw-body": ["raw-body@3.0.0", "", { "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", "iconv-lite": "0.6.3", "unpipe": "1.0.0" } }, "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g=="], + + "rollup": ["rollup@4.40.2", "", { "dependencies": { "@types/estree": "1.0.7" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.40.2", "@rollup/rollup-android-arm64": "4.40.2", "@rollup/rollup-darwin-arm64": "4.40.2", "@rollup/rollup-darwin-x64": "4.40.2", "@rollup/rollup-freebsd-arm64": "4.40.2", "@rollup/rollup-freebsd-x64": "4.40.2", "@rollup/rollup-linux-arm-gnueabihf": "4.40.2", "@rollup/rollup-linux-arm-musleabihf": "4.40.2", "@rollup/rollup-linux-arm64-gnu": "4.40.2", "@rollup/rollup-linux-arm64-musl": "4.40.2", "@rollup/rollup-linux-loongarch64-gnu": "4.40.2", "@rollup/rollup-linux-powerpc64le-gnu": "4.40.2", "@rollup/rollup-linux-riscv64-gnu": "4.40.2", "@rollup/rollup-linux-riscv64-musl": "4.40.2", "@rollup/rollup-linux-s390x-gnu": "4.40.2", "@rollup/rollup-linux-x64-gnu": "4.40.2", "@rollup/rollup-linux-x64-musl": "4.40.2", "@rollup/rollup-win32-arm64-msvc": "4.40.2", "@rollup/rollup-win32-ia32-msvc": "4.40.2", "@rollup/rollup-win32-x64-msvc": "4.40.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-tfUOg6DTP4rhQ3VjOO6B4wyrJnGOX85requAXvqYTHsOgb2TFJdZ3aWpT8W2kPoypSGP7dZUyzxJ9ee4buM5Fg=="], + + "rollup-plugin-inject": ["rollup-plugin-inject@3.0.2", "", { "dependencies": { "estree-walker": "^0.6.1", "magic-string": "^0.25.3", "rollup-pluginutils": "^2.8.1" } }, "sha512-ptg9PQwzs3orn4jkgXJ74bfs5vYz1NCZlSQMBUA0wKcGp5i5pA1AO3fOUEte8enhGUC+iapTCzEWw2jEFFUO/w=="], + + "rollup-plugin-node-polyfills": ["rollup-plugin-node-polyfills@0.2.1", "", { "dependencies": { "rollup-plugin-inject": "^3.0.0" } }, "sha512-4kCrKPTJ6sK4/gLL/U5QzVT8cxJcofO0OU74tnB19F40cmuAKSzH5/siithxlofFEjwvw1YAhPmbvGNA6jEroA=="], + + "rollup-pluginutils": ["rollup-pluginutils@2.8.2", "", { "dependencies": { "estree-walker": "^0.6.1" } }, "sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ=="], + + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="], + + "send": ["send@1.2.0", "", { "dependencies": { "debug": "^4.3.5", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.0", "mime-types": "^3.0.1", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.1" } }, "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw=="], + + "serve-static": ["serve-static@2.2.0", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "simple-swizzle": ["simple-swizzle@0.2.2", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg=="], + + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "sourcemap-codec": ["sourcemap-codec@1.4.8", "", {}, "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA=="], + + "stacktracey": ["stacktracey@2.1.8", "", { "dependencies": { "as-table": "^1.0.36", "get-source": "^2.0.12" } }, "sha512-Kpij9riA+UNg7TnphqjH7/CzctQ/owJGNbFkfEeve4Z4uxT5+JapVLFXcsurIfN34gnTWZNJ/f7NMG0E8JDzTw=="], + + "statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="], + + "stoppable": ["stoppable@1.1.0", "", {}, "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], + + "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], + + "ufo": ["ufo@1.6.1", "", {}, "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA=="], + + "undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], + + "unenv": ["unenv@2.0.0-rc.14", "", { "dependencies": { "defu": "^6.1.4", "exsolve": "^1.0.1", "ohash": "^2.0.10", "pathe": "^2.0.3", "ufo": "^1.5.4" } }, "sha512-od496pShMen7nOy5VmVJCnq8rptd45vh6Nx/r2iPbrba6pa6p+tS2ywuIHRZ/OBvSbQZB0kWvpO9XBNVFXHD3Q=="], + + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + + "vite": ["vite@5.4.19", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "workerd": ["workerd@1.20250408.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20250408.0", "@cloudflare/workerd-darwin-arm64": "1.20250408.0", "@cloudflare/workerd-linux-64": "1.20250408.0", "@cloudflare/workerd-linux-arm64": "1.20250408.0", "@cloudflare/workerd-windows-64": "1.20250408.0" }, "bin": { "workerd": "bin/workerd" } }, "sha512-bBUX+UsvpzAqiWFNeZrlZmDGddiGZdBBbftZJz2wE6iUg/cIAJeVQYTtS/3ahaicguoLBz4nJiDo8luqM9fx1A=="], + + "wrangler": ["wrangler@3.114.8", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.3.4", "@cloudflare/unenv-preset": "2.0.2", "@esbuild-plugins/node-globals-polyfill": "0.2.3", "@esbuild-plugins/node-modules-polyfill": "0.2.2", "blake3-wasm": "2.1.5", "esbuild": "0.17.19", "miniflare": "3.20250408.1", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.14", "workerd": "1.20250408.0" }, "optionalDependencies": { "fsevents": "~2.3.2", "sharp": "^0.33.5" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20250408.0" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-4qLxkXnviQDhFiQe2xIgZGEaDkNHdVKyX7dsQ70PO3blXxYwQof34yRH9oZOyT8c8g/BeIM+f9a6qmegeJX5ZA=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + + "youch": ["youch@3.3.4", "", { "dependencies": { "cookie": "^0.7.1", "mustache": "^4.2.0", "stacktracey": "^2.1.8" } }, "sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg=="], + + "zod": ["zod@3.24.4", "", {}, "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.24.5", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g=="], + + "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "miniflare/zod": ["zod@3.22.3", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="], + + "router/path-to-regexp": ["path-to-regexp@8.2.0", "", {}, "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ=="], + + "wrangler/esbuild": ["esbuild@0.17.19", "", { "optionalDependencies": { "@esbuild/android-arm": "0.17.19", "@esbuild/android-arm64": "0.17.19", "@esbuild/android-x64": "0.17.19", "@esbuild/darwin-arm64": "0.17.19", "@esbuild/darwin-x64": "0.17.19", "@esbuild/freebsd-arm64": "0.17.19", "@esbuild/freebsd-x64": "0.17.19", "@esbuild/linux-arm": "0.17.19", "@esbuild/linux-arm64": "0.17.19", "@esbuild/linux-ia32": "0.17.19", "@esbuild/linux-loong64": "0.17.19", "@esbuild/linux-mips64el": "0.17.19", "@esbuild/linux-ppc64": "0.17.19", "@esbuild/linux-riscv64": "0.17.19", "@esbuild/linux-s390x": "0.17.19", "@esbuild/linux-x64": "0.17.19", "@esbuild/netbsd-x64": "0.17.19", "@esbuild/openbsd-x64": "0.17.19", "@esbuild/sunos-x64": "0.17.19", "@esbuild/win32-arm64": "0.17.19", "@esbuild/win32-ia32": "0.17.19", "@esbuild/win32-x64": "0.17.19" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw=="], + + "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.17.19", "", { "os": "android", "cpu": "arm" }, "sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A=="], + + "wrangler/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.17.19", "", { "os": "android", "cpu": "arm64" }, "sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA=="], + + "wrangler/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.17.19", "", { "os": "android", "cpu": "x64" }, "sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww=="], + + "wrangler/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.17.19", "", { "os": "darwin", "cpu": "arm64" }, "sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg=="], + + "wrangler/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.17.19", "", { "os": "darwin", "cpu": "x64" }, "sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw=="], + + "wrangler/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.17.19", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ=="], + + "wrangler/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.17.19", "", { "os": "freebsd", "cpu": "x64" }, "sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ=="], + + "wrangler/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.17.19", "", { "os": "linux", "cpu": "arm" }, "sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA=="], + + "wrangler/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.17.19", "", { "os": "linux", "cpu": "arm64" }, "sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg=="], + + "wrangler/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.17.19", "", { "os": "linux", "cpu": "ia32" }, "sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ=="], + + "wrangler/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.17.19", "", { "os": "linux", "cpu": "none" }, "sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ=="], + + "wrangler/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.17.19", "", { "os": "linux", "cpu": "none" }, "sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A=="], + + "wrangler/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.17.19", "", { "os": "linux", "cpu": "ppc64" }, "sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg=="], + + "wrangler/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.17.19", "", { "os": "linux", "cpu": "none" }, "sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA=="], + + "wrangler/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.17.19", "", { "os": "linux", "cpu": "s390x" }, "sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q=="], + + "wrangler/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.17.19", "", { "os": "linux", "cpu": "x64" }, "sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw=="], + + "wrangler/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.17.19", "", { "os": "none", "cpu": "x64" }, "sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q=="], + + "wrangler/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.17.19", "", { "os": "openbsd", "cpu": "x64" }, "sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g=="], + + "wrangler/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.17.19", "", { "os": "sunos", "cpu": "x64" }, "sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg=="], + + "wrangler/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.17.19", "", { "os": "win32", "cpu": "arm64" }, "sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag=="], + + "wrangler/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.17.19", "", { "os": "win32", "cpu": "ia32" }, "sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw=="], + + "wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.17.19", "", { "os": "win32", "cpu": "x64" }, "sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA=="], + } +} diff --git a/shortcut-mcp-worker/copy-tools.sh b/shortcut-mcp-worker/copy-tools.sh new file mode 100755 index 0000000..769a5eb --- /dev/null +++ b/shortcut-mcp-worker/copy-tools.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +# Create the needed directories +mkdir -p src/tools/utils + +# Copy base tools +cp ../src/tools/base.ts src/tools/ +cp ../src/tools/epics.ts src/tools/ +cp ../src/tools/iterations.ts src/tools/ +cp ../src/tools/objectives.ts src/tools/ +cp ../src/tools/stories.ts src/tools/ +cp ../src/tools/teams.ts src/tools/ +cp ../src/tools/user.ts src/tools/ +cp ../src/tools/workflows.ts src/tools/ + +# Copy utils +cp ../src/tools/utils/format.ts src/tools/utils/ +cp ../src/tools/utils/search.ts src/tools/utils/ +cp ../src/tools/utils/validation.ts src/tools/utils/ + +# Make the script executable +chmod +x copy-tools.sh + +echo "Tools copied successfully!" \ No newline at end of file diff --git a/shortcut-mcp-worker/package.json b/shortcut-mcp-worker/package.json new file mode 100644 index 0000000..ee2cbbc --- /dev/null +++ b/shortcut-mcp-worker/package.json @@ -0,0 +1,26 @@ +{ + "name": "shortcut-mcp-worker", + "version": "0.1.0", + "description": "Shortcut MCP Server running on Cloudflare Workers", + "type": "module", + "scripts": { + "build": "tsc && vite build", + "dev": "wrangler dev src/index.ts", + "deploy": "wrangler deploy", + "test": "bun test", + "test:watch": "bun test --watch" + }, + "dependencies": { + "@cloudflare/workers-oauth-provider": "^0.0.5", + "@modelcontextprotocol/sdk": "^1.6.1", + "@shortcut/client": "^1.1.0", + "@shortcut/mcp-tools": "file:../shortcut-mcp-tools", + "zod": "^3.24.2" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20240515.0", + "typescript": "^5.0.0", + "vite": "^5.0.0", + "wrangler": "^3.0.0" + } +} \ No newline at end of file diff --git a/shortcut-mcp-worker/src/auth.ts b/shortcut-mcp-worker/src/auth.ts new file mode 100644 index 0000000..678d67a --- /dev/null +++ b/shortcut-mcp-worker/src/auth.ts @@ -0,0 +1,89 @@ +import { OAuthProvider } from "@cloudflare/workers-oauth-provider"; + +// Define the OAuth configuration interface +export interface ShortcutOAuthConfig { + clientId: string; + clientSecret: string; + authorizationUrl: string; + tokenUrl: string; + callbackUrl: string; +} + +// Create an OAuth provider with the provided configuration +export function createOAuthProvider(config: ShortcutOAuthConfig) { + return new OAuthProvider({ + clientId: config.clientId, + clientSecret: config.clientSecret, + authorizationUrl: config.authorizationUrl, + tokenUrl: config.tokenUrl, + callbackUrl: config.callbackUrl, + scope: "api", // The scope required for Shortcut API access + }); +} + +// Handle OAuth routes +export async function handleAuth(request: Request, env: Record) { + const url = new URL(request.url); + const provider = createOAuthProvider({ + clientId: env.SHORTCUT_CLIENT_ID, + clientSecret: env.SHORTCUT_CLIENT_SECRET, + authorizationUrl: "https://app.shortcut.com/oauth/authorize", + tokenUrl: "https://app.shortcut.com/oauth/token", + callbackUrl: `${url.origin}/oauth/callback`, + }); + + // Handle authorization request + if (url.pathname === "/oauth/authorize") { + return provider.handleAuthorize(request); + } + + // Handle callback from OAuth provider + if (url.pathname === "/oauth/callback") { + return provider.handleCallback(request); + } + + // Return not found for other routes + return new Response("Not found", { status: 404 }); +} + +// Extract token from various sources +export async function getTokenFromRequest( + request: Request, + env: Record, +): Promise { + // Try Authorization header first (Bearer token) + const authHeader = request.headers.get("Authorization"); + if (authHeader?.startsWith("Bearer ")) { + return authHeader.substring(7); + } + + // Try cookies next + const cookies = parseCookies(request.headers.get("Cookie") || ""); + if (cookies.shortcut_token) { + return cookies.shortcut_token; + } + + // Try query parameters last + const url = new URL(request.url); + const queryToken = url.searchParams.get("token"); + if (queryToken) { + return queryToken; + } + + // No token found + return null; +} + +// Helper to parse cookies +function parseCookies(cookieString: string): Record { + return cookieString + .split(";") + .map((pair) => pair.trim().split("=")) + .reduce( + (cookies, [key, value]) => { + if (key && value) cookies[key] = value; + return cookies; + }, + {} as Record, + ); +} diff --git a/shortcut-mcp-worker/src/index.ts b/shortcut-mcp-worker/src/index.ts new file mode 100644 index 0000000..ee3ac30 --- /dev/null +++ b/shortcut-mcp-worker/src/index.ts @@ -0,0 +1,200 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { ShortcutClient } from "@shortcut/client"; +import { getTokenFromRequest, handleAuth } from "./auth"; +import { HttpSseTransport } from "./transport/http-sse"; + +// Import shared tools +import { + EpicTools, + IterationTools, + ObjectiveTools, + ShortcutClientWrapper, + StoryTools, + TeamTools, + UserTools, + WorkflowTools, +} from "@shortcut/mcp-tools"; + +// Read package.json for name and version +// These would normally be imported with: import { name, version } from "../package.json" +const pkgInfo = { + name: "@shortcut/mcp", + version: "0.1.0", +}; + +export interface Env { + // Environment variables for authentication + SHORTCUT_CLIENT_ID: string; + SHORTCUT_CLIENT_SECRET: string; +} + +export default { + async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { + // Handle OPTIONS requests for CORS + if (request.method === "OPTIONS") { + return handleCorsRequest(); + } + + const url = new URL(request.url); + + // Handle OAuth routes + if (url.pathname.startsWith("/oauth")) { + return handleAuth(request, env); + } + + // Handle MCP SSE endpoint + if (url.pathname === "/sse" && request.method === "POST") { + try { + // Get token from session, headers, or query parameters + const token = await getTokenFromRequest(request, env); + if (!token) { + return new Response("Unauthorized: API token is required", { + status: 401, + headers: getCorsHeaders(), + }); + } + + // Create MCP server + const server = new McpServer({ + name: pkgInfo.name, + version: pkgInfo.version, + }); + + // Initialize client with token + const client = new ShortcutClientWrapper(new ShortcutClient(token)); + + // Register all tools + UserTools.create(client, server); + StoryTools.create(client, server); + IterationTools.create(client, server); + EpicTools.create(client, server); + ObjectiveTools.create(client, server); + TeamTools.create(client, server); + WorkflowTools.create(client, server); + + // Create transport and connect + const transport = new HttpSseTransport(request, env); + + // Connect transport to server (this will start handling the request) + ctx.waitUntil(server.connect(transport)); + + // Return SSE response stream + return await transport.getResponse(); + } catch (error) { + console.error("Error handling MCP request:", error); + return new Response(`Error: ${error.message}`, { + status: 500, + headers: getCorsHeaders(), + }); + } + } + + // Return connection instructions for home page + if (url.pathname === "/" || url.pathname === "") { + return new Response( + ` + + + + Shortcut MCP Server + + + + + +

Shortcut MCP Server

+

This is a Model Context Protocol (MCP) server for Shortcut. It allows AI assistants to interact with your Shortcut projects.

+ +

Authentication

+

To use this server, you need to authenticate:

+

Authenticate with Shortcut

+ +

Connection Information

+

MCP endpoint: ${url.origin}/sse

+ +

Configuration

+

To connect an AI assistant to this MCP server, add the following to your configuration:

+ +

Claude Code

+
{
+  "projects": {
+    "mcpServers": {
+      "shortcut": {
+        "mcpUrl": "${url.origin}/sse"
+      }
+    }
+  }
+}
+ +

Cursor

+
{
+  "mcpServers": {
+    "shortcut": {
+      "mcpUrl": "${url.origin}/sse"
+    }
+  }
+}
+ +

Windsurf

+
{
+  "mcpServers": {
+    "shortcut": {
+      "mcpUrl": "${url.origin}/sse"
+    }
+  }
+}
+ + + `, + { + headers: { + "Content-Type": "text/html", + ...getCorsHeaders(), + }, + }, + ); + } + + // Handle 404 for all other routes + return new Response("Not found", { + status: 404, + headers: getCorsHeaders(), + }); + }, +}; + +// Helper function for CORS headers +function getCorsHeaders() { + return { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + }; +} + +// Handle CORS preflight requests +function handleCorsRequest() { + return new Response(null, { + status: 204, + headers: getCorsHeaders(), + }); +} diff --git a/shortcut-mcp-worker/src/transport/http-sse.ts b/shortcut-mcp-worker/src/transport/http-sse.ts new file mode 100644 index 0000000..0dde52a --- /dev/null +++ b/shortcut-mcp-worker/src/transport/http-sse.ts @@ -0,0 +1,76 @@ +import type { McpServerTransport } from "@modelcontextprotocol/sdk/server/transport.js"; +import type { McpRequest, McpResponse } from "@modelcontextprotocol/sdk/server/types.js"; + +export class HttpSseTransport implements McpServerTransport { + private controller = new AbortController(); + private responseStream: ReadableStream | null = null; + private writer: WritableStreamDefaultWriter | null = null; + private requestPromise: Promise | null = null; + + constructor( + private request: Request, + private env: Record, + ) { + this.setupStream(); + this.requestPromise = this.parseRequest(); + } + + private setupStream() { + const { readable, writable } = new TransformStream(); + this.responseStream = readable; + this.writer = writable.getWriter(); + } + + private async parseRequest(): Promise { + try { + const body = await this.request.json(); + return body as McpRequest; + } catch (error) { + console.error("Failed to parse request:", error); + throw new Error(`Invalid MCP request: ${error.message}`); + } + } + + async receive(): Promise { + if (!this.requestPromise) { + throw new Error("Request not initialized"); + } + return this.requestPromise; + } + + async send(response: McpResponse): Promise { + if (!this.writer) { + throw new Error("Writer not initialized"); + } + + const encoder = new TextEncoder(); + const data = `data: ${JSON.stringify(response)}\n\n`; + await this.writer.write(encoder.encode(data)); + } + + async getResponse(): Promise { + if (!this.responseStream) { + throw new Error("Response stream not initialized"); + } + + return new Response(this.responseStream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + }, + }); + } + + close(): void { + this.controller.abort(); + if (this.writer) { + this.writer.close().catch((err) => { + console.error("Error closing writer:", err); + }); + } + } +} diff --git a/shortcut-mcp-worker/test/cors.test.ts b/shortcut-mcp-worker/test/cors.test.ts new file mode 100644 index 0000000..66cf31e --- /dev/null +++ b/shortcut-mcp-worker/test/cors.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "bun:test"; +import { env } from "./setup"; + +// Helper function to simulate what the worker does for CORS +function getCorsHeaders() { + return { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + }; +} + +function handleCorsRequest() { + return new Response(null, { + status: 204, + headers: getCorsHeaders(), + }); +} + +describe("CORS handling", () => { + test("preflight OPTIONS request returns correct status and headers", () => { + const response = handleCorsRequest(); + + // Check status code + expect(response.status).toBe(204); + + // Check headers + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); + expect(response.headers.get("Access-Control-Allow-Methods")).toContain("OPTIONS"); + expect(response.headers.get("Access-Control-Allow-Headers")).toContain("Content-Type"); + expect(response.headers.get("Access-Control-Allow-Headers")).toContain("Authorization"); + }); + + test("CORS headers allow requests from any origin", () => { + const headers = getCorsHeaders(); + expect(headers["Access-Control-Allow-Origin"]).toBe("*"); + }); + + test("CORS headers allow POST method for MCP requests", () => { + const headers = getCorsHeaders(); + expect(headers["Access-Control-Allow-Methods"]).toContain("POST"); + }); + + test("CORS headers allow Authorization header for authentication", () => { + const headers = getCorsHeaders(); + expect(headers["Access-Control-Allow-Headers"]).toContain("Authorization"); + }); +}); \ No newline at end of file diff --git a/shortcut-mcp-worker/test/helpers/cloudflare-env.ts b/shortcut-mcp-worker/test/helpers/cloudflare-env.ts new file mode 100644 index 0000000..ac4f394 --- /dev/null +++ b/shortcut-mcp-worker/test/helpers/cloudflare-env.ts @@ -0,0 +1,13 @@ +/** + * Creates a mock Cloudflare environment for testing + */ +export function makeCloudflareEnv(vars = {}) { + return { + // Default environment variables + SHORTCUT_CLIENT_ID: "test-client-id", + SHORTCUT_CLIENT_SECRET: "test-client-secret", + // Override with any provided vars + ...vars, + // Add other environment bindings as needed + }; +} \ No newline at end of file diff --git a/shortcut-mcp-worker/test/helpers/mock-oauth.ts b/shortcut-mcp-worker/test/helpers/mock-oauth.ts new file mode 100644 index 0000000..f172778 --- /dev/null +++ b/shortcut-mcp-worker/test/helpers/mock-oauth.ts @@ -0,0 +1,16 @@ +/** + * Mock implementation of the OAuth provider + */ +export class OAuthProvider { + constructor(config: any) { + // Store config if needed + } + + handleAuthorize(request: Request) { + return new Response("Authorize mock response", { status: 200 }); + } + + handleCallback(request: Request) { + return new Response("Callback mock response", { status: 200 }); + } +} \ No newline at end of file diff --git a/shortcut-mcp-worker/test/protocol-compliance.test.ts b/shortcut-mcp-worker/test/protocol-compliance.test.ts new file mode 100644 index 0000000..2f5ad8c --- /dev/null +++ b/shortcut-mcp-worker/test/protocol-compliance.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test"; +import { env } from "./setup"; + +// Define some sample MCP messages for testing +const validMcpRequest = { + request_id: "req-123", + action: "call-tool", + payload: { + tool_name: "get-story", + args: { storyPublicId: 123 } + } +}; + +const invalidMcpRequest = { + // Missing request_id + action: "call-tool", + payload: { + tool_name: "get-story", + args: { storyPublicId: 123 } + } +}; + +const validMcpResponse = { + response_id: "req-123", + action: "tool-result", + payload: { + content: [{ type: "text", text: "Result text" }] + } +}; + +describe("MCP Protocol Compliance", () => { + test("valid MCP request has required fields", () => { + expect(validMcpRequest).toHaveProperty("request_id"); + expect(validMcpRequest).toHaveProperty("action"); + expect(typeof validMcpRequest.request_id).toBe("string"); + expect(typeof validMcpRequest.action).toBe("string"); + }); + + test("invalid MCP request missing required fields", () => { + expect(invalidMcpRequest).not.toHaveProperty("request_id"); + }); + + test("valid MCP response has required fields", () => { + expect(validMcpResponse).toHaveProperty("response_id"); + expect(validMcpResponse).toHaveProperty("action"); + expect(validMcpResponse).toHaveProperty("payload"); + expect(typeof validMcpResponse.response_id).toBe("string"); + expect(typeof validMcpResponse.action).toBe("string"); + }); + + test("MCP response payload contains content array", () => { + expect(validMcpResponse.payload).toHaveProperty("content"); + expect(Array.isArray(validMcpResponse.payload.content)).toBe(true); + }); + + test("MCP response content items have required fields", () => { + const contentItem = validMcpResponse.payload.content[0]; + expect(contentItem).toHaveProperty("type"); + expect(contentItem).toHaveProperty("text"); + expect(contentItem.type).toBe("text"); + expect(typeof contentItem.text).toBe("string"); + }); +}); \ No newline at end of file diff --git a/shortcut-mcp-worker/test/setup.ts b/shortcut-mcp-worker/test/setup.ts new file mode 100644 index 0000000..4916553 --- /dev/null +++ b/shortcut-mcp-worker/test/setup.ts @@ -0,0 +1,19 @@ +import { beforeAll, afterAll, afterEach } from "bun:test"; +import { makeCloudflareEnv } from "./helpers/cloudflare-env"; + +// Create a mock environment for tests +let env: ReturnType; + +beforeAll(() => { + env = makeCloudflareEnv(); +}); + +afterEach(() => { + // Clean up any mocks or test data +}); + +afterAll(() => { + // Cleanup any resources +}); + +export { env }; \ No newline at end of file diff --git a/shortcut-mcp-worker/test/token-extraction.test.ts b/shortcut-mcp-worker/test/token-extraction.test.ts new file mode 100644 index 0000000..570befb --- /dev/null +++ b/shortcut-mcp-worker/test/token-extraction.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from "bun:test"; +import { env } from "./setup"; + +// Simplified version of the token extraction logic for testing +function parseCookies(cookieString: string): Record { + return cookieString + .split(";") + .map(pair => pair.trim().split("=")) + .reduce((cookies, [key, value]) => { + if (key && value) cookies[key] = value; + return cookies; + }, {} as Record); +} + +async function getTokenFromRequest(request: Request): Promise { + // Try Authorization header first (Bearer token) + const authHeader = request.headers.get("Authorization"); + if (authHeader?.startsWith("Bearer ")) { + return authHeader.substring(7); + } + + // Try cookies next + const cookies = parseCookies(request.headers.get("Cookie") || ""); + if (cookies.shortcut_token) { + return cookies.shortcut_token; + } + + // Try query parameters last + const url = new URL(request.url); + const queryToken = url.searchParams.get("token"); + if (queryToken) { + return queryToken; + } + + // No token found + return null; +} + +describe("Token Extraction", () => { + test("should extract Bearer token from Authorization header", async () => { + const request = new Request("https://example.com/sse", { + headers: { + "Authorization": "Bearer test-token" + } + }); + + const token = await getTokenFromRequest(request); + expect(token).toBe("test-token"); + }); + + test("should extract token from cookies", async () => { + const request = new Request("https://example.com/sse", { + headers: { + "Cookie": "shortcut_token=cookie-token; other=value" + } + }); + + const token = await getTokenFromRequest(request); + expect(token).toBe("cookie-token"); + }); + + test("should extract token from query parameters", async () => { + const request = new Request("https://example.com/sse?token=query-token"); + + const token = await getTokenFromRequest(request); + expect(token).toBe("query-token"); + }); + + test("should return null when no token is found", async () => { + const request = new Request("https://example.com/sse"); + + const token = await getTokenFromRequest(request); + expect(token).toBeNull(); + }); +}); \ No newline at end of file diff --git a/shortcut-mcp-worker/test/transport/http-sse.test.ts b/shortcut-mcp-worker/test/transport/http-sse.test.ts new file mode 100644 index 0000000..2983914 --- /dev/null +++ b/shortcut-mcp-worker/test/transport/http-sse.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test, mock, beforeEach } from "bun:test"; +import { HttpSseTransport } from "../../src/transport/http-sse"; +import { env } from "../setup"; + +describe("HttpSseTransport", () => { + let request: Request; + + beforeEach(() => { + // Create a fresh request for each test + request = new Request("https://example.com/sse", { + method: "POST", + body: JSON.stringify({ request_id: "123", action: "ping" }) + }); + }); + + test("should initialize with a request", () => { + const transport = new HttpSseTransport(request, env); + expect(transport).toBeDefined(); + }); + + test("should receive MCP requests", async () => { + const mcpRequest = { request_id: "123", action: "ping" }; + const request = new Request("https://example.com/sse", { + method: "POST", + body: JSON.stringify(mcpRequest) + }); + + const transport = new HttpSseTransport(request, env); + const receivedRequest = await transport.receive(); + + expect(receivedRequest).toEqual(mcpRequest); + }); + + test("should format responses as server-sent events", async () => { + const transport = new HttpSseTransport(request, env); + const response = await transport.getResponse(); + + expect(response.headers.get("Content-Type")).toBe("text/event-stream"); + expect(response.headers.get("Cache-Control")).toBe("no-cache"); + expect(response.headers.get("Connection")).toBe("keep-alive"); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); + }); + + test("should throw error when writer not initialized", async () => { + const transport = new HttpSseTransport(request, env); + + // Replace the writer with null to simulate uninitialized state + Object.defineProperty(transport, "writer", { + value: null, + writable: true + }); + + try { + await transport.send({ response_id: "123", action: "pong" }); + expect.fail("Expected to throw an error"); + } catch (error) { + expect(error.message).toBe("Writer not initialized"); + } + }); + + test("should throw error when requestPromise not initialized", async () => { + const transport = new HttpSseTransport(request, env); + + // Replace the requestPromise with null to simulate uninitialized state + Object.defineProperty(transport, "requestPromise", { + value: null, + writable: true + }); + + try { + await transport.receive(); + expect.fail("Expected to throw an error"); + } catch (error) { + expect(error.message).toBe("Request not initialized"); + } + }); + + test("should throw error when responseStream not initialized", async () => { + const transport = new HttpSseTransport(request, env); + + // Replace the responseStream with null to simulate uninitialized state + Object.defineProperty(transport, "responseStream", { + value: null, + writable: true + }); + + try { + await transport.getResponse(); + expect.fail("Expected to throw an error"); + } catch (error) { + expect(error.message).toBe("Response stream not initialized"); + } + }); +}); \ No newline at end of file diff --git a/shortcut-mcp-worker/test/worker.test.ts b/shortcut-mcp-worker/test/worker.test.ts new file mode 100644 index 0000000..a32f531 --- /dev/null +++ b/shortcut-mcp-worker/test/worker.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test, mock } from "bun:test"; +import { env } from "./setup"; + +// Since we can't easily import the worker with its dependencies, +// we'll test some core behaviors without importing it directly + +describe("Worker behaviors", () => { + test("CORS headers should be properly formatted", () => { + const headers = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + }; + + expect(headers["Access-Control-Allow-Origin"]).toBe("*"); + expect(headers["Access-Control-Allow-Methods"]).toContain("OPTIONS"); + expect(headers["Access-Control-Allow-Headers"]).toContain("Authorization"); + }); + + test("Response status codes should follow HTTP standards", () => { + // Define expected status codes + const statusCodes = { + success: 200, + created: 201, + noContent: 204, + unauthorized: 401, + notFound: 404, + serverError: 500 + }; + + // Verify they match expected HTTP status codes + expect(statusCodes.success).toBe(200); + expect(statusCodes.unauthorized).toBe(401); + expect(statusCodes.notFound).toBe(404); + }); +}); \ No newline at end of file diff --git a/shortcut-mcp-worker/tsconfig.json b/shortcut-mcp-worker/tsconfig.json new file mode 100644 index 0000000..2b95b18 --- /dev/null +++ b/shortcut-mcp-worker/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "esModuleInterop": true, + "strict": true, + "lib": ["ES2020"], + "skipLibCheck": true, + "isolatedModules": true, + "noEmit": true, + "types": ["@cloudflare/workers-types"], + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src"] +} \ No newline at end of file diff --git a/shortcut-mcp-worker/vite.config.ts b/shortcut-mcp-worker/vite.config.ts new file mode 100644 index 0000000..778b97e --- /dev/null +++ b/shortcut-mcp-worker/vite.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + build: { + lib: { + entry: './src/index.ts', + formats: ['es'], + fileName: 'worker', + }, + rollupOptions: { + external: [], + }, + }, + resolve: { + alias: { + '@': '/src', + }, + }, +}); \ No newline at end of file diff --git a/shortcut-mcp-worker/wrangler.toml b/shortcut-mcp-worker/wrangler.toml new file mode 100644 index 0000000..8894109 --- /dev/null +++ b/shortcut-mcp-worker/wrangler.toml @@ -0,0 +1,14 @@ +name = "shortcut-mcp-worker" +main = "src/index.ts" +compatibility_date = "2023-10-30" + +[build] +command = "npm run build" + +[vars] +# Public variables here + +# Add KV storage for sessions if needed +# kv_namespaces = [ +# { binding = "SESSIONS", id = "xxxx", preview_id = "xxxx" } +# ] \ No newline at end of file diff --git a/src/server.ts b/src/server.ts index 06fda2b..6aac06e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,16 +1,19 @@ -import { ShortcutClientWrapper } from "@/client/shortcut"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { ShortcutClient } from "@shortcut/client"; import { name, version } from "../package.json"; -import { EpicTools } from "./tools/epics"; -import { IterationTools } from "./tools/iterations"; -import { ObjectiveTools } from "./tools/objectives"; -import { StoryTools } from "./tools/stories"; -import { TeamTools } from "./tools/teams"; -import { UserTools } from "./tools/user"; -import { WorkflowTools } from "./tools/workflows"; +// Import shared tools +import { + EpicTools, + IterationTools, + ObjectiveTools, + ShortcutClientWrapper, + StoryTools, + TeamTools, + UserTools, + WorkflowTools, +} from "@shortcut/mcp-tools"; let apiToken = process.env.SHORTCUT_API_TOKEN; diff --git a/tsconfig.json b/tsconfig.json index d0ff3e8..b45d84d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,8 +18,9 @@ "noUncheckedSideEffectImports": true, "baseUrl": ".", "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "@shortcut/mcp-tools": ["./shortcut-mcp-tools/src"] } }, - "include": ["src"] -} + "include": ["src", "shortcut-mcp-tools/src"] +} \ No newline at end of file