Skip to content

Commit 4d274b8

Browse files
Merge pull request #92 from useshortcut/amcd/per-tool-filtering
Allow per tool filtering.
2 parents e1e6201 + 52d429e commit 4d274b8

21 files changed

+605
-571
lines changed

README.md

Lines changed: 44 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -129,67 +129,72 @@ Or you can edit the local JSON file directly:
129129

130130
### Stories
131131

132-
- **get-story** - Get a single Shortcut story by ID
133-
- **search-stories** - Find Shortcut stories with filtering and search options
134-
- **get-story-branch-name** - Get the recommended branch name (based on workspace settings) for a specific story.
135-
- **create-story** - Create a new Shortcut story
136-
- **update-story** - Update an existing Shortcut story
137-
- **upload-file-to-story** - Upload a file and link it to a story
138-
- **assign-current-user-as-owner** - Assign the current user as the owner of a story
139-
- **unassign-current-user-as-owner** - Unassign the current user as the owner of a story
140-
- **create-story-comment** - Create a comment on a story
141-
- **add-task-to-story** - Add a task to a story
142-
- **update-task** - Update a task in a story
143-
- **add-relation-to-story** - Add a story relationship (relates to, blocks, duplicates, etc.)
144-
- **add-external-link-to-story** - Add an external link to a Shortcut story
145-
- **remove-external-link-from-story** - Remove an external link from a Shortcut story
146-
- **set-story-external-links** - Replace all external links on a story with a new set of links
147-
- **get-stories-by-external-link** - Find all stories that contain a specific external link
132+
- **stories-get-by-id** - Get a single Shortcut story by ID
133+
- **stories-search** - Find Shortcut stories with filtering and search options
134+
- **stories-get-branch-name** - Get the recommended branch name (based on workspace settings) for a specific story.
135+
- **stories-create** - Create a new Shortcut story
136+
- **stories-update** - Update an existing Shortcut story
137+
- **stories-upload-file** - Upload a file and link it to a story
138+
- **stories-assign-current-user** - Assign the current user as the owner of a story
139+
- **stories-unassign-current-user** - Unassign the current user as the owner of a story
140+
- **stories-create-comment** - Create a comment on a story
141+
- **stories-add-task** - Add a task to a story
142+
- **stories-update-task** - Update a task in a story
143+
- **stories-add-relation** - Add a story relationship (relates to, blocks, duplicates, etc.)
144+
- **stories-add-external-link** - Add an external link to a Shortcut story
145+
- **stories-remove-external-link** - Remove an external link from a Shortcut story
146+
- **stories-set-external-links** - Replace all external links on a story with a new set of links
147+
- **stories-get-by-external-link** - Find all stories that contain a specific external link
148148

149149
### Epics
150150

151-
- **get-epic** - Get a Shortcut epic by ID
152-
- **search-epics** - Find Shortcut epics with filtering and search options
153-
- **create-epic** - Create a new Shortcut epic
151+
- **epics-get-by-id** - Get a Shortcut epic by ID
152+
- **epics-search** - Find Shortcut epics with filtering and search options
153+
- **epics-create** - Create a new Shortcut epic
154154

155155
### Iterations
156156

157-
- **get-iteration-stories** - Get stories in a specific iteration by iteration ID
158-
- **get-iteration** - Get a Shortcut iteration by ID
159-
- **search-iterations** - Find Shortcut iterations with filtering and search options
160-
- **create-iteration** - Create a new Shortcut iteration with start/end dates
161-
- **get-active-iterations** - Get active iterations for the current user based on team memberships
162-
- **get-upcoming-iterations** - Get upcoming iterations for the current user based on team memberships
157+
- **iterations-get-stories** - Get stories in a specific iteration by iteration ID
158+
- **iterations-get-by-id** - Get a Shortcut iteration by ID
159+
- **iterations-search** - Find Shortcut iterations with filtering and search options
160+
- **iterations-create** - Create a new Shortcut iteration with start/end dates
161+
- **iterations-get-active** - Get active iterations for the current user based on team memberships
162+
- **iterations-get-upcoming** - Get upcoming iterations for the current user based on team memberships
163163

164164
### Objectives
165165

166-
- **get-objective** - Get a Shortcut objective by ID
167-
- **search-objectives** - Find Shortcut objectives with filtering and search options
166+
- **objectives-get-by-id** - Get a Shortcut objective by ID
167+
- **objectives-search** - Find Shortcut objectives with filtering and search options
168168

169169
### Teams
170170

171-
- **get-team** - Get a Shortcut team by ID
172-
- **list-teams** - List all Shortcut teams
171+
- **teams-get-by-id** - Get a Shortcut team by ID
172+
- **teams-list** - List all Shortcut teams
173173

174174
### Workflows
175175

176-
- **get-default-workflow** - Get the default workflow for a specific team or the workspace default
177-
- **get-workflow** - Get a Shortcut workflow by ID
178-
- **list-workflows** - List all Shortcut workflows
176+
- **workflows-get-default** - Get the default workflow for a specific team or the workspace default
177+
- **workflows-get-by-id** - Get a Shortcut workflow by ID
178+
- **workflows-list** - List all Shortcut workflows
179179

180180
### Users
181181

182-
- **get-current-user** - Get the current user information
183-
- **get-current-user-teams** - Get a list of teams where the current user is a member
184-
- **list-users** - Get all workspace users
182+
- **users-get-current** - Get the current user information
183+
- **users-get-current-teams** - Get a list of teams where the current user is a member
184+
- **users-list** - Get all workspace users
185185

186186
### Documents
187187

188-
- **create-document** - Create a new document in Shortcut with HTML content
188+
- **documents-create** - Create a new document in Shortcut with HTML content
189189

190190
## Limit tools
191191

192-
You can limit the tools available to the LLM by setting the `SHORTCUT_TOOLS` environment variable to a comma-separated list of entity types.
192+
You can limit the tools available to the LLM by setting the `SHORTCUT_TOOLS` environment variable to a comma-separated list.
193+
194+
- Tools can be limited by entity type by just adding the entity, eg `stories` or `epics`.
195+
- Individual tools can also be limitied by their full name, eg `stories-get-by-id` or `epics-search`.
196+
197+
By default, all tools are enabled.
193198

194199
Example:
195200

@@ -204,14 +209,14 @@ Example:
204209
],
205210
"env": {
206211
"SHORTCUT_API_TOKEN": "<YOUR_SHORTCUT_API_TOKEN>",
207-
"SHORTCUT_TOOLS": "stories,epics"
212+
"SHORTCUT_TOOLS": "stories,epics,iterations-create"
208213
}
209214
}
210215
}
211216
}
212217
```
213218

214-
The following values are accepted:
219+
The following values are accepted in addition to the full tool names listed above under [Available Tools](#available-tools):
215220

216221
- `users`
217222
- `stories`

src/mcp/CustomMcpServer.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { CustomMcpServer } from "./CustomMcpServer";
3+
4+
describe("CustomMcpServer", () => {
5+
test("should allow read only tools when readonly is true", () => {
6+
const server = new CustomMcpServer({ readonly: true, tools: null });
7+
expect(
8+
server.addToolWithReadAccess("test", "test", async () => {
9+
return { content: [] };
10+
}),
11+
).not.toBeNull();
12+
});
13+
14+
test("should not allow write tools when readonly is true", () => {
15+
const server = new CustomMcpServer({ readonly: true, tools: null });
16+
expect(
17+
server.addToolWithWriteAccess("test", "test", async () => {
18+
return { content: [] };
19+
}),
20+
).toBeNull();
21+
});
22+
23+
test("should allow write tools when readonly is false", () => {
24+
const server = new CustomMcpServer({ readonly: false, tools: null });
25+
expect(
26+
server.addToolWithWriteAccess("test", "test", async () => {
27+
return { content: [] };
28+
}),
29+
).not.toBeNull();
30+
});
31+
32+
test("should only allow tools in the tools list", () => {
33+
const server = new CustomMcpServer({ readonly: false, tools: ["test"] });
34+
expect(
35+
server.addToolWithReadAccess("test", "test", async () => {
36+
return { content: [] };
37+
}),
38+
).not.toBeNull();
39+
expect(
40+
server.addToolWithReadAccess("test-sub-tool", "test", async () => {
41+
return { content: [] };
42+
}),
43+
).not.toBeNull();
44+
expect(
45+
server.addToolWithReadAccess("test2", "test", async () => {
46+
return { content: [] };
47+
}),
48+
).toBeNull();
49+
});
50+
});

src/mcp/CustomMcpServer.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import {
2+
McpServer,
3+
type RegisteredTool,
4+
type ToolCallback,
5+
} from "@modelcontextprotocol/sdk/server/mcp.js";
6+
import type { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js";
7+
import type { ZodRawShape } from "zod";
8+
import { name, version } from "../../package.json";
9+
10+
export class CustomMcpServer extends McpServer {
11+
private readonly: boolean;
12+
private tools: Set<string>;
13+
14+
constructor({ readonly, tools }: { readonly: boolean; tools: string[] | null | undefined }) {
15+
super({ name, version });
16+
this.readonly = readonly;
17+
this.tools = new Set(tools || []);
18+
}
19+
20+
shouldAddTool(name: string) {
21+
console.log("Checking tool:", name, this.tools.size);
22+
if (!this.tools.size) return true;
23+
const [entityType] = name.split("-");
24+
if (this.tools.has(entityType) || this.tools.has(name)) return true;
25+
return false;
26+
}
27+
28+
// Overloads for addToolWithWriteAccess to match all variants of the base tool() method
29+
addToolWithWriteAccess(name: string, cb: ToolCallback): RegisteredTool | null;
30+
addToolWithWriteAccess(
31+
name: string,
32+
description: string,
33+
cb: ToolCallback,
34+
): RegisteredTool | null;
35+
addToolWithWriteAccess<Args extends ZodRawShape>(
36+
name: string,
37+
paramsSchemaOrAnnotations: Args | ToolAnnotations,
38+
cb: ToolCallback<Args>,
39+
): RegisteredTool | null;
40+
addToolWithWriteAccess<Args extends ZodRawShape>(
41+
name: string,
42+
description: string,
43+
paramsSchemaOrAnnotations: Args | ToolAnnotations,
44+
cb: ToolCallback<Args>,
45+
): RegisteredTool | null;
46+
addToolWithWriteAccess<Args extends ZodRawShape>(
47+
name: string,
48+
paramsSchema: Args,
49+
annotations: ToolAnnotations,
50+
cb: ToolCallback<Args>,
51+
): RegisteredTool | null;
52+
addToolWithWriteAccess<Args extends ZodRawShape>(
53+
name: string,
54+
description: string,
55+
paramsSchema: Args,
56+
annotations: ToolAnnotations,
57+
cb: ToolCallback<Args>,
58+
): RegisteredTool | null;
59+
addToolWithWriteAccess(...args: any[]): RegisteredTool | null {
60+
if (this.readonly) return null;
61+
if (!this.shouldAddTool(args[0])) return null;
62+
return (super.tool as any)(...args);
63+
}
64+
65+
// Overloads for addToolWithReadAccess to match all variants of the base tool() method
66+
addToolWithReadAccess(name: string, cb: ToolCallback): RegisteredTool | null;
67+
addToolWithReadAccess(name: string, description: string, cb: ToolCallback): RegisteredTool | null;
68+
addToolWithReadAccess<Args extends ZodRawShape>(
69+
name: string,
70+
paramsSchemaOrAnnotations: Args | ToolAnnotations,
71+
cb: ToolCallback<Args>,
72+
): RegisteredTool | null;
73+
addToolWithReadAccess<Args extends ZodRawShape>(
74+
name: string,
75+
description: string,
76+
paramsSchemaOrAnnotations: Args | ToolAnnotations,
77+
cb: ToolCallback<Args>,
78+
): RegisteredTool | null;
79+
addToolWithReadAccess<Args extends ZodRawShape>(
80+
name: string,
81+
paramsSchema: Args,
82+
annotations: ToolAnnotations,
83+
cb: ToolCallback<Args>,
84+
): RegisteredTool | null;
85+
addToolWithReadAccess<Args extends ZodRawShape>(
86+
name: string,
87+
description: string,
88+
paramsSchema: Args,
89+
annotations: ToolAnnotations,
90+
cb: ToolCallback<Args>,
91+
): RegisteredTool | null;
92+
addToolWithReadAccess(...args: any[]): RegisteredTool | null {
93+
if (!this.shouldAddTool(args[0])) return null;
94+
return (super.tool as any)(...args);
95+
}
96+
97+
tool(): never {
98+
throw new Error("Call addToolWithReadAccess or addToolWithWriteAccess instead.");
99+
}
100+
}

src/server.ts

Lines changed: 19 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
1-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
21
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
32
import { ShortcutClient } from "@shortcut/client";
43
import { ShortcutClientWrapper } from "@/client/shortcut";
5-
import { name, version } from "../package.json";
6-
4+
import { CustomMcpServer } from "./mcp/CustomMcpServer";
75
import { DocumentTools } from "./tools/documents";
86
import { EpicTools } from "./tools/epics";
97
import { IterationTools } from "./tools/iterations";
@@ -15,9 +13,10 @@ import { WorkflowTools } from "./tools/workflows";
1513

1614
let apiToken = process.env.SHORTCUT_API_TOKEN;
1715
let isReadonly = process.env.SHORTCUT_READONLY === "true";
18-
let enabledTools = process.env.SHORTCUT_TOOLS?.length
19-
? process.env.SHORTCUT_TOOLS.split(",").map((tool) => tool.trim())
20-
: null;
16+
let enabledTools = (process.env.SHORTCUT_TOOLS || "")
17+
.split(",")
18+
.map((tool) => tool.trim())
19+
.filter(Boolean);
2120

2221
// If a setting is provided as an argument, use it instead of the environment variable.
2322
if (process.argv.length >= 3) {
@@ -27,7 +26,11 @@ if (process.argv.length >= 3) {
2726
.forEach(([name, value]) => {
2827
if (name === "SHORTCUT_API_TOKEN") apiToken = value;
2928
if (name === "SHORTCUT_READONLY") isReadonly = value === "true";
30-
if (name === "SHORTCUT_TOOLS") enabledTools = value.split(",").map((tool) => tool.trim());
29+
if (name === "SHORTCUT_TOOLS")
30+
enabledTools = value
31+
.split(",")
32+
.map((tool) => tool.trim())
33+
.filter(Boolean);
3134
});
3235
}
3336

@@ -36,23 +39,18 @@ if (!apiToken) {
3639
process.exit(1);
3740
}
3841

39-
const server = new McpServer({ name, version });
42+
const server = new CustomMcpServer({ readonly: isReadonly, tools: enabledTools });
4043
const client = new ShortcutClientWrapper(new ShortcutClient(apiToken));
4144

42-
// Helper function to check if a tool should be enabled. All tools are enabled by default unless specified otherwise.
43-
const areToolsEnabled = (toolName: string) => {
44-
return !enabledTools || enabledTools.includes(toolName);
45-
};
46-
4745
// The order these are created impacts the order they are listed to the LLM. Most important tools should be at the top.
48-
if (areToolsEnabled("users")) UserTools.create(client, server, isReadonly);
49-
if (areToolsEnabled("stories")) StoryTools.create(client, server, isReadonly);
50-
if (areToolsEnabled("iterations")) IterationTools.create(client, server, isReadonly);
51-
if (areToolsEnabled("epics")) EpicTools.create(client, server, isReadonly);
52-
if (areToolsEnabled("objectives")) ObjectiveTools.create(client, server, isReadonly);
53-
if (areToolsEnabled("teams")) TeamTools.create(client, server, isReadonly);
54-
if (areToolsEnabled("workflows")) WorkflowTools.create(client, server, isReadonly);
55-
if (areToolsEnabled("documents")) DocumentTools.create(client, server, isReadonly);
46+
UserTools.create(client, server);
47+
StoryTools.create(client, server);
48+
IterationTools.create(client, server);
49+
EpicTools.create(client, server);
50+
ObjectiveTools.create(client, server);
51+
TeamTools.create(client, server);
52+
WorkflowTools.create(client, server);
53+
DocumentTools.create(client, server);
5654

5755
async function startServer() {
5856
try {

src/tools/base.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -125,10 +125,7 @@ export type SimplifiedKind = "simple" | "list" | "full";
125125
* Base class for all tools.
126126
*/
127127
export class BaseTools {
128-
constructor(
129-
protected client: ShortcutClientWrapper,
130-
protected isReadonly = false,
131-
) {}
128+
constructor(protected client: ShortcutClientWrapper) {}
132129

133130
private renameEntityProps<T extends Record<string, unknown>>(entity: T) {
134131
if (!entity || typeof entity !== "object") return entity;

0 commit comments

Comments
 (0)