Skip to content

Commit 10ddf0f

Browse files
committed
add skills tool & clean up
1 parent 088c749 commit 10ddf0f

File tree

8 files changed

+316
-59
lines changed

8 files changed

+316
-59
lines changed

README.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,56 @@
11
# Topcoder Model Context Protocol (MCP) Server
2+
3+
## Authentication Based Access via Guards
4+
5+
Tools/Resources/Prompts support authentication via TC JWT and/or M2M JWT. Providing JWT in the requests to the MCP server will result in specific listings and bahavior based on JWT access level/roles/permissions.
6+
7+
#### Using `authGuard` - requires TC jwt presence for access
8+
9+
```ts
10+
@Tool({
11+
name: 'query-tc-challenges-private',
12+
description:
13+
'Returns a list of Topcoder challenges based on the query parameters.',
14+
parameters: QUERY_CHALLENGES_TOOL_PARAMETERS,
15+
outputSchema: QUERY_CHALLENGES_TOOL_OUTPUT_SCHEMA,
16+
annotations: {
17+
title: 'Query Public Topcoder Challenges',
18+
readOnlyHint: true,
19+
},
20+
canActivate: authGuard,
21+
})
22+
```
23+
24+
#### Using `checkHasUserRole(Role.Admin)` - TC Role based guard
25+
26+
```ts
27+
@Tool({
28+
name: 'query-tc-challenges-protected',
29+
description:
30+
'Returns a list of Topcoder challenges based on the query parameters.',
31+
parameters: QUERY_CHALLENGES_TOOL_PARAMETERS,
32+
outputSchema: QUERY_CHALLENGES_TOOL_OUTPUT_SCHEMA,
33+
annotations: {
34+
title: 'Query Public Topcoder Challenges',
35+
readOnlyHint: true,
36+
},
37+
canActivate: checkHasUserRole(Role.Admin),
38+
})
39+
```
40+
41+
#### Using `canActivate: checkM2MScope(M2mScope.QueryPublicChallenges)` - M2M based access via scopes
42+
43+
```ts
44+
@Tool({
45+
name: 'query-tc-challenges-m2m',
46+
description:
47+
'Returns a list of Topcoder challenges based on the query parameters.',
48+
parameters: QUERY_CHALLENGES_TOOL_PARAMETERS,
49+
outputSchema: QUERY_CHALLENGES_TOOL_OUTPUT_SCHEMA,
50+
annotations: {
51+
title: 'Query Public Topcoder Challenges',
52+
readOnlyHint: true,
53+
},
54+
canActivate: checkM2MScope(M2mScope.QueryPublicChallenges),
55+
})
56+
```

src/mcp/tools/challenges/queryChallenges.tool.ts

Lines changed: 2 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,6 @@ import { QUERY_CHALLENGES_TOOL_PARAMETERS } from './queryChallenges.parameters';
55
import { TopcoderChallengesService } from 'src/shared/topcoder/challenges.service';
66
import { Logger } from 'src/shared/global';
77
import { QUERY_CHALLENGES_TOOL_OUTPUT_SCHEMA } from './queryChallenges.output';
8-
import {
9-
authGuard,
10-
checkHasUserRole,
11-
checkM2MScope,
12-
} from 'src/core/auth/guards';
13-
import { M2mScope, Role } from 'src/core/auth/auth.constants';
148

159
@Injectable()
1610
export class QueryChallengesTool {
@@ -127,61 +121,13 @@ export class QueryChallengesTool {
127121
}
128122

129123
@Tool({
130-
name: 'query-tc-challenges-private',
124+
name: 'query-tc-challenges',
131125
description:
132126
'Returns a list of Topcoder challenges based on the query parameters.',
133127
parameters: QUERY_CHALLENGES_TOOL_PARAMETERS,
134128
outputSchema: QUERY_CHALLENGES_TOOL_OUTPUT_SCHEMA,
135129
annotations: {
136-
title: 'Query Public Topcoder Challenges',
137-
readOnlyHint: true,
138-
},
139-
canActivate: authGuard,
140-
})
141-
async queryChallengesPrivate(params) {
142-
return this._queryChallenges(params);
143-
}
144-
145-
@Tool({
146-
name: 'query-tc-challenges-protected',
147-
description:
148-
'Returns a list of Topcoder challenges based on the query parameters.',
149-
parameters: QUERY_CHALLENGES_TOOL_PARAMETERS,
150-
outputSchema: QUERY_CHALLENGES_TOOL_OUTPUT_SCHEMA,
151-
annotations: {
152-
title: 'Query Public Topcoder Challenges',
153-
readOnlyHint: true,
154-
},
155-
canActivate: checkHasUserRole(Role.Admin),
156-
})
157-
async queryChallengesProtected(params) {
158-
return this._queryChallenges(params);
159-
}
160-
161-
@Tool({
162-
name: 'query-tc-challenges-m2m',
163-
description:
164-
'Returns a list of Topcoder challenges based on the query parameters.',
165-
parameters: QUERY_CHALLENGES_TOOL_PARAMETERS,
166-
outputSchema: QUERY_CHALLENGES_TOOL_OUTPUT_SCHEMA,
167-
annotations: {
168-
title: 'Query Public Topcoder Challenges',
169-
readOnlyHint: true,
170-
},
171-
canActivate: checkM2MScope(M2mScope.QueryPublicChallenges),
172-
})
173-
async queryChallengesM2m(params) {
174-
return this._queryChallenges(params);
175-
}
176-
177-
@Tool({
178-
name: 'query-tc-challenges-public',
179-
description:
180-
'Returns a list of public Topcoder challenges based on the query parameters.',
181-
parameters: QUERY_CHALLENGES_TOOL_PARAMETERS,
182-
outputSchema: QUERY_CHALLENGES_TOOL_OUTPUT_SCHEMA,
183-
annotations: {
184-
title: 'Query Public Topcoder Challenges',
130+
title: 'Query Topcoder Challenges',
185131
readOnlyHint: true,
186132
},
187133
})
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { z } from 'zod';
2+
3+
export const QUERY_SKILLS_TOOL_OUTPUT_SCHEMA = z.object({
4+
page: z.number().describe('Current page number in the paginated response'),
5+
pageSize: z
6+
.number()
7+
.describe(
8+
'Number of standardized skills per page in the paginated response',
9+
),
10+
total: z
11+
.number()
12+
.describe('Total number of standardized skills matching the query'),
13+
data: z
14+
.array(
15+
z
16+
.object({
17+
id: z.string().describe('Unique identifier for the skill'),
18+
name: z.string().describe('Skill name'),
19+
description: z
20+
.string()
21+
.describe('Detailed description of the standardized skill'),
22+
category: z
23+
.object({
24+
id: z.string().describe('Unique identifier for the category'),
25+
name: z.string().describe('Category name'),
26+
description: z
27+
.string()
28+
.optional()
29+
.describe('Detailed description of the category'),
30+
})
31+
.describe('Category to which the skill belongs'),
32+
})
33+
.describe('Standardized skill object'),
34+
)
35+
.describe("Array of Topcoder's standardized skills"),
36+
});
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { z } from 'zod';
2+
3+
export const QUERY_SKILLS_TOOL_PARAMETERS = z.object({
4+
name: z
5+
.array(z.string())
6+
.optional()
7+
.describe('Filter by skill names, exact match.'),
8+
skillId: z
9+
.array(z.string().uuid())
10+
.optional()
11+
.describe('Filter by skill IDs, exact match.'),
12+
sortBy: z
13+
.enum(['name', 'description', 'created_at', 'updated_at'])
14+
.optional()
15+
.describe('Sort challenges by a specific field'),
16+
sortOrder: z.enum(['asc', 'desc']).optional().describe('Sort order'),
17+
page: z
18+
.number()
19+
.gte(1)
20+
.default(1)
21+
.optional()
22+
.describe('Page number for pagination, starting from 1'),
23+
perPage: z
24+
.number()
25+
.gte(1)
26+
.lte(100)
27+
.default(20)
28+
.optional()
29+
.describe('Number of standardized skills per page, between 1 and 100'),
30+
});
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { Injectable, Inject } from '@nestjs/common';
2+
import { Tool } from '@tc/mcp-nest';
3+
import { REQUEST } from '@nestjs/core';
4+
import { Logger } from 'src/shared/global';
5+
import { QUERY_SKILLS_TOOL_PARAMETERS } from './querySkills.parameters';
6+
import { QUERY_SKILLS_TOOL_OUTPUT_SCHEMA } from './querySkills.output';
7+
import { TopcoderSkillsService } from 'src/shared/topcoder/skills.service';
8+
9+
@Injectable()
10+
export class QuerySkillsTool {
11+
private readonly logger = new Logger(QuerySkillsTool.name);
12+
13+
constructor(
14+
private readonly topcoderSkillsService: TopcoderSkillsService,
15+
@Inject(REQUEST) private readonly request: any,
16+
) {}
17+
18+
private async _querySkills(params) {
19+
// Validate the input parameters
20+
const validatedParams = QUERY_SKILLS_TOOL_PARAMETERS.safeParse(params);
21+
if (!validatedParams.success) {
22+
this.logger.error(
23+
`Invalid parameters provided: ${JSON.stringify(validatedParams.error.errors)}`,
24+
);
25+
26+
// Return an error response with the validation errors
27+
return {
28+
content: [
29+
{
30+
type: 'text',
31+
text: `Invalid parameters: ${JSON.stringify(validatedParams.error.errors)}`,
32+
},
33+
],
34+
isError: true,
35+
};
36+
}
37+
38+
// Get the challenges from the Topcoder challenges API
39+
// and handle any errors that may occur
40+
try {
41+
const accessToken = this.request.headers['authorization']?.split(' ')[1];
42+
const skills = await this.topcoderSkillsService.fetchSkills(
43+
validatedParams.data,
44+
accessToken,
45+
);
46+
47+
if (skills.status < 200 || skills.status >= 300) {
48+
this.logger.error(
49+
`Failed to fetch skills from Topcoder API: ${skills.statusText}`,
50+
);
51+
try {
52+
this.logger.error(skills.data);
53+
} catch (e) {
54+
this.logger.error('Failed to log skills error', e);
55+
}
56+
57+
// Return an error response if the API call fails
58+
return {
59+
content: [
60+
{
61+
type: 'text',
62+
text: `Error fetching skills: ${skills.statusText}`,
63+
},
64+
],
65+
isError: true,
66+
};
67+
}
68+
69+
// Axios response: data is already parsed, headers are plain object
70+
const skillsData = skills.data;
71+
72+
return {
73+
content: [
74+
{
75+
type: 'text',
76+
text: JSON.stringify({
77+
page: Number(skills.headers['x-page']) || 1,
78+
pageSize:
79+
Number(skills.headers['x-per-page']) ||
80+
(Array.isArray(skillsData) ? skillsData.length : 0) ||
81+
0,
82+
total:
83+
Number(skills.headers['x-total']) ||
84+
(Array.isArray(skillsData) ? skillsData.length : 0) ||
85+
0,
86+
data: skillsData,
87+
}),
88+
},
89+
],
90+
structuredContent: {
91+
page: Number(skills.headers['x-page']) || 1,
92+
pageSize:
93+
Number(skills.headers['x-per-page']) ||
94+
(Array.isArray(skillsData) ? skillsData.length : 0) ||
95+
0,
96+
total:
97+
Number(skills.headers['x-total']) ||
98+
(Array.isArray(skillsData) ? skillsData.length : 0) ||
99+
0,
100+
data: skillsData,
101+
},
102+
};
103+
} catch (error) {
104+
this.logger.error(`Error fetching skills: ${error.message}`, error);
105+
return {
106+
content: [
107+
{
108+
type: 'text',
109+
text: `Error fetching skills: ${error.message}`,
110+
},
111+
],
112+
isError: true,
113+
};
114+
}
115+
}
116+
117+
@Tool({
118+
name: 'query-tc-skills',
119+
description:
120+
'Returns a list of standardized skills from Topcoder platform, filtered and sorted based on the provided parameters.',
121+
parameters: QUERY_SKILLS_TOOL_PARAMETERS,
122+
outputSchema: QUERY_SKILLS_TOOL_OUTPUT_SCHEMA,
123+
annotations: {
124+
title: 'Query Topcoder Standardized Skills',
125+
readOnlyHint: true,
126+
},
127+
})
128+
async querySkills(params) {
129+
return this._querySkills(params);
130+
}
131+
}

src/mcp/tools/tools.module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { Module } from '@nestjs/common';
22
import { QueryChallengesTool } from './challenges/queryChallenges.tool';
33
import { TopcoderModule } from 'src/shared/topcoder/topcoder.module';
4+
import { QuerySkillsTool } from './skills/querySkills.tool';
45

56
@Module({
67
imports: [TopcoderModule],
78
controllers: [],
8-
providers: [QueryChallengesTool],
9+
providers: [QueryChallengesTool, QuerySkillsTool],
910
})
1011
export class ToolsModule {}

0 commit comments

Comments
 (0)