Skip to content

Commit ed8953a

Browse files
committed
add completions step
1 parent c29cbd1 commit ed8953a

File tree

57 files changed

+2227
-4
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

57 files changed

+2227
-4
lines changed
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Resource Template Completions
2+
3+
👨‍💼 Now that our users have a bunch of entries and tags, they'll want to be able
4+
to search for them. Let's add some completions to our resource templates to
5+
make this possible.
6+
7+
This involves a simple callback as a part of our `ResourceTemplate` definition.
8+
For example:
9+
10+
```ts lines=8-17
11+
import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'
12+
13+
const NAMES = ['Alice', 'Bob', 'Charlie']
14+
15+
agent.server.resource(
16+
'hello',
17+
new ResourceTemplate('hello://{name}', {
18+
complete: {
19+
// this is an object with a key for each parameter in the resource template
20+
// it accepts the value the user has typed so far and returns a string array
21+
// of valid matching values
22+
async name(value) {
23+
// this is a function that returns a string array of valid matching values
24+
// for the `name` parameter
25+
return NAMES.filter((name) => name.includes(value))
26+
},
27+
},
28+
list: async () => {
29+
// ...
30+
},
31+
}),
32+
{ description: 'Say hello to anyone by name!' },
33+
async (uri, { name }) => {
34+
// ...
35+
},
36+
)
37+
```
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"name": "exercises_03.resources_03.problem.completion",
3+
"private": true,
4+
"type": "module",
5+
"scripts": {
6+
"dev": "mcp-dev",
7+
"dev:mcp": "tsx src/index.ts",
8+
"test": "vitest",
9+
"typecheck": "tsc",
10+
"inspect": "mcp-inspector"
11+
},
12+
"dependencies": {
13+
"@epic-web/invariant": "^1.0.0",
14+
"@modelcontextprotocol/sdk": "^1.11.0",
15+
"zod": "^3.24.3"
16+
},
17+
"devDependencies": {
18+
"@epic-web/config": "^1.19.0",
19+
"@epic-web/mcp-dev": "*",
20+
"@faker-js/faker": "^9.8.0",
21+
"@modelcontextprotocol/inspector": "^0.13.0",
22+
"@types/node": "^22.15.2",
23+
"tsx": "^4.19.3",
24+
"typescript": "^5.8.3",
25+
"vitest": "^3.1.2"
26+
},
27+
"license": "GPL-3.0-only"
28+
}

exercises/03.resources/03.problem.embedded/src/resources.ts renamed to exercises/03.resources/03.problem.completion/src/resources.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ export async function initializeResources(agent: EpicMeMCP) {
2424
agent.server.resource(
2525
'tag',
2626
new ResourceTemplate('epicme://tags/{id}', {
27+
// 🐨 add a `complete` callback for the `id` parameter
28+
// 💰 const tags = await agent.db.getTags()
2729
list: async () => {
2830
const tags = await agent.db.getTags()
2931
return {
@@ -54,6 +56,8 @@ export async function initializeResources(agent: EpicMeMCP) {
5456
agent.server.resource(
5557
'entry',
5658
new ResourceTemplate('epicme://entries/{id}', {
59+
// 🐨 add a `complete` callback for the `id` parameter
60+
// 💰 const entries = await agent.db.getEntries()
5761
list: async () => {
5862
const entries = await agent.db.getEntries()
5963
return {
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
import { invariant } from '@epic-web/invariant'
2+
import { type CallToolResult } from '@modelcontextprotocol/sdk/types.js'
3+
import { z } from 'zod'
4+
import { createEntryInputSchema, createTagInputSchema } from './db/schema.ts'
5+
import { type EpicMeMCP } from './index.ts'
6+
7+
export async function initializeTools(agent: EpicMeMCP) {
8+
// Entry Tools
9+
agent.server.tool(
10+
'create_entry',
11+
'Create a new journal entry',
12+
createEntryInputSchema,
13+
async (entry) => {
14+
const createdEntry = await agent.db.createEntry(entry)
15+
if (entry.tags) {
16+
for (const tagId of entry.tags) {
17+
await agent.db.addTagToEntry({
18+
entryId: createdEntry.id,
19+
tagId,
20+
})
21+
}
22+
}
23+
return createReply(
24+
`Entry "${createdEntry.title}" created successfully with ID "${createdEntry.id}"`,
25+
)
26+
},
27+
)
28+
29+
agent.server.tool(
30+
'get_entry',
31+
'Get a journal entry by ID',
32+
{
33+
id: z.number().describe('The ID of the entry'),
34+
},
35+
async ({ id }) => {
36+
const entry = await agent.db.getEntry(id)
37+
invariant(entry, `Entry with ID "${id}" not found`)
38+
return createReply(entry)
39+
},
40+
)
41+
42+
agent.server.tool(
43+
'list_entries',
44+
'List all journal entries',
45+
{
46+
tagIds: z
47+
.array(z.number())
48+
.optional()
49+
.describe('Optional array of tag IDs to filter entries by'),
50+
},
51+
async ({ tagIds }) => {
52+
const entries = await agent.db.listEntries(tagIds)
53+
return createReply(entries)
54+
},
55+
)
56+
57+
agent.server.tool(
58+
'update_entry',
59+
'Update a journal entry. Fields that are not provided (or set to undefined) will not be updated. Fields that are set to null or any other value will be updated.',
60+
{
61+
id: z.number(),
62+
title: z.string().optional().describe('The title of the entry'),
63+
content: z.string().optional().describe('The content of the entry'),
64+
mood: z
65+
.string()
66+
.nullable()
67+
.optional()
68+
.describe(
69+
'The mood of the entry (for example: "happy", "sad", "anxious", "excited")',
70+
),
71+
location: z
72+
.string()
73+
.nullable()
74+
.optional()
75+
.describe(
76+
'The location of the entry (for example: "home", "work", "school", "park")',
77+
),
78+
weather: z
79+
.string()
80+
.nullable()
81+
.optional()
82+
.describe(
83+
'The weather of the entry (for example: "sunny", "cloudy", "rainy", "snowy")',
84+
),
85+
isPrivate: z
86+
.number()
87+
.optional()
88+
.describe('Whether the entry is private (1 for private, 0 for public)'),
89+
isFavorite: z
90+
.number()
91+
.optional()
92+
.describe(
93+
'Whether the entry is a favorite (1 for favorite, 0 for not favorite)',
94+
),
95+
},
96+
async ({ id, ...updates }) => {
97+
const existingEntry = await agent.db.getEntry(id)
98+
invariant(existingEntry, `Entry with ID "${id}" not found`)
99+
const updatedEntry = await agent.db.updateEntry(id, updates)
100+
return createReply(
101+
`Entry "${updatedEntry.title}" (ID: ${id}) updated successfully`,
102+
)
103+
},
104+
)
105+
106+
agent.server.tool(
107+
'delete_entry',
108+
'Delete a journal entry',
109+
{
110+
id: z.number().describe('The ID of the entry'),
111+
},
112+
async ({ id }) => {
113+
const existingEntry = await agent.db.getEntry(id)
114+
invariant(existingEntry, `Entry with ID "${id}" not found`)
115+
await agent.db.deleteEntry(id)
116+
return createReply(
117+
`Entry "${existingEntry.title}" (ID: ${id}) deleted successfully`,
118+
)
119+
},
120+
)
121+
122+
// Tag Tools
123+
agent.server.tool(
124+
'create_tag',
125+
'Create a new tag',
126+
createTagInputSchema,
127+
async (tag) => {
128+
const createdTag = await agent.db.createTag(tag)
129+
return createReply(
130+
`Tag "${createdTag.name}" created successfully with ID "${createdTag.id}"`,
131+
)
132+
},
133+
)
134+
135+
agent.server.tool(
136+
'get_tag',
137+
'Get a tag by ID',
138+
{
139+
id: z.number().describe('The ID of the tag'),
140+
},
141+
async ({ id }) => {
142+
const tag = await agent.db.getTag(id)
143+
invariant(tag, `Tag ID "${id}" not found`)
144+
return createReply(tag)
145+
},
146+
)
147+
148+
agent.server.tool('list_tags', 'List all tags', async () => {
149+
const tags = await agent.db.listTags()
150+
return createReply(tags)
151+
})
152+
153+
agent.server.tool(
154+
'update_tag',
155+
'Update a tag',
156+
{
157+
id: z.number(),
158+
...Object.fromEntries(
159+
Object.entries(createTagInputSchema).map(([key, value]) => [
160+
key,
161+
value.nullable().optional(),
162+
]),
163+
),
164+
},
165+
async ({ id, ...updates }) => {
166+
const updatedTag = await agent.db.updateTag(id, updates)
167+
return createReply(
168+
`Tag "${updatedTag.name}" (ID: ${id}) updated successfully`,
169+
)
170+
},
171+
)
172+
173+
agent.server.tool(
174+
'delete_tag',
175+
'Delete a tag',
176+
{
177+
id: z.number().describe('The ID of the tag'),
178+
},
179+
async ({ id }) => {
180+
const existingTag = await agent.db.getTag(id)
181+
invariant(existingTag, `Tag ID "${id}" not found`)
182+
await agent.db.deleteTag(id)
183+
return createReply(
184+
`Tag "${existingTag.name}" (ID: ${id}) deleted successfully`,
185+
)
186+
},
187+
)
188+
189+
// Entry Tag Tools
190+
agent.server.tool(
191+
'add_tag_to_entry',
192+
'Add a tag to an entry',
193+
{
194+
entryId: z.number().describe('The ID of the entry'),
195+
tagId: z.number().describe('The ID of the tag'),
196+
},
197+
async ({ entryId, tagId }) => {
198+
const tag = await agent.db.getTag(tagId)
199+
const entry = await agent.db.getEntry(entryId)
200+
invariant(tag, `Tag ${tagId} not found`)
201+
invariant(entry, `Entry with ID "${entryId}" not found`)
202+
const entryTag = await agent.db.addTagToEntry({
203+
entryId,
204+
tagId,
205+
})
206+
return createReply(
207+
`Tag "${tag.name}" (ID: ${entryTag.tagId}) added to entry "${entry.title}" (ID: ${entryTag.entryId}) successfully`,
208+
)
209+
},
210+
)
211+
}
212+
213+
function createReply(text: any): CallToolResult {
214+
if (typeof text === 'string') {
215+
return { content: [{ type: 'text', text }] }
216+
} else {
217+
return {
218+
content: [{ type: 'text', text: JSON.stringify(text) }],
219+
}
220+
}
221+
}

0 commit comments

Comments
 (0)