A Toolshed plugin is a self-contained bundle of tools that agents can call through the MCP protocol. You define each tool’s input and output schema, write a handler function that does the actual work, then assemble everything into a plugin manifest that Toolshed registers and routes automatically. This guide walks you through creating a plugin from scratch, from installation to registration.
Install the SDK
Add @toolshed/sdk and its peer dependencies to your project:
npm install @toolshed/sdk zod
@toolshed/sdk re-exports defineTool and definePlugin — the two functions you need to build a plugin. All schemas are written with Zod, which the SDK converts to JSON Schema automatically.
Every tool has a dot-separated path that uniquely identifies it in the catalog. Paths follow a strict format:
- Lowercase identifiers separated by dots:
github.issues.create
- Pattern:
namespace.resource.verb (or namespace.verb_noun for flat plugins)
- Must match
^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$
The namespace (first segment) matches the plugin’s id. Tools from the GitHub plugin all start with github., tools from a notes plugin would start with notes., and so on. This namespacing prevents collisions across plugins and is how policy patterns target specific tool sets.
Write tool descriptions that answer three questions: what the tool does, when an agent should call it, and what it returns. For example: “List open issues in a GitHub repository, filtered by state and labels. Returns issue numbers, titles, states, and URLs. Use when you need to triage or summarise a repo’s open work.” Descriptions that answer all three questions dramatically improve agent tool selection.
defineTool is a thin wrapper that provides full TypeScript type inference for your handler. You pass a ToolConfig object with a path, name, description, Zod schemas, and an async handler:
import { defineTool } from "@toolshed/sdk"
import { z } from "zod"
const notesCreate = defineTool({
path: "notes.create",
name: "Create Note",
description:
"Create a new note with a title and body. Returns the note's ID and creation timestamp. " +
"Use when the user wants to save information for later retrieval.",
inputSchema: z.object({
title: z.string().describe("Note title, max 200 characters"),
body: z.string().describe("Note body in plain text or Markdown"),
tags: z.array(z.string()).optional().describe("Optional tag list for organisation"),
}),
outputSchema: z.object({
id: z.string(),
createdAt: z.string().datetime(),
}),
async handler(ctx, input) {
// ctx.userId identifies the calling user
// ctx.auth.getToken() vends OAuth tokens for connected providers
// Replace db.notes with your own data store:
const note = await db.notes.insert({
userId: ctx.userId,
title: input.title,
body: input.body,
tags: input.tags ?? [],
})
return { id: note.id, createdAt: note.createdAt.toISOString() }
},
})
The handler receives a PluginContext as its first argument. Through ctx you can:
| Property | Type | Purpose |
|---|
ctx.userId | string | ID of the user invoking the tool |
ctx.role | Role | The caller’s role (patterns, id, name) |
ctx.auth.getToken(provider) | Promise<string> | Fetch a stored OAuth token for a connected provider |
ctx.elicit(request) | Promise<ElicitationResponse> | Pause execution and request user approval |
ctx.logger | Logger | Structured logging (info / warn / error) |
Any tool that creates, updates, deletes, or sends data should set destructive: true. This tells Toolshed to require user approval before the handler runs:
const notesDelete = defineTool({
path: "notes.delete",
name: "Delete Note",
description: "Permanently delete a note by ID. This action cannot be undone.",
destructive: true,
inputSchema: z.object({
id: z.string().describe("Note ID to delete"),
}),
outputSchema: z.object({
deleted: z.boolean(),
}),
async handler(ctx, input) {
// elicit() must be called before mutating — see the Elicitation guide
const approval = await ctx.elicit({
toolPath: "notes.delete",
message: `Permanently delete note ${input.id}?`,
args: input,
type: "approval",
})
if (!approval.approved) {
throw new Error("Deletion denied by user")
}
// Replace db.notes with your own data store:
await db.notes.delete({ id: input.id, userId: ctx.userId })
return { deleted: true }
},
})
Assemble the plugin with definePlugin()
Once you have your tools, pass them to definePlugin() along with the plugin metadata and any OAuth providers it needs:
import { definePlugin } from "@toolshed/sdk"
const plugin = definePlugin({
id: "notes",
name: "Notes",
description: "Create, read, update, and delete personal notes.",
authProviders: [], // no OAuth required for this plugin
tools: [notesCreate, notesDelete],
})
export default plugin
The id field must match the namespace prefix used in your tool paths. If your plugin id is "notes", all tool paths must start with notes..
Adding OAuth providers
If your tools call external APIs that need user-authorised tokens, declare the providers in authProviders. Here is how the GitHub plugin does it:
const plugin = definePlugin({
id: "github",
name: "GitHub",
description: "GitHub issues, pull requests, and repository operations.",
authProviders: [
{
id: "github",
type: "oauth2",
provider: "github",
scopes: ["repo", "read:user"],
},
],
tools: [issuesList, issuesCreate, repoSearch],
})
Once a user connects their GitHub account (see the Connect Integrations guide), every handler can call ctx.auth.getToken("github") to receive a live access token.
Complete plugin file
Here is a full, working plugin file you can use as a starting point:
import { definePlugin, defineTool } from "@toolshed/sdk"
import { z } from "zod"
// --- Tools ---
const notesCreate = defineTool({
path: "notes.create",
name: "Create Note",
description:
"Create a new note with a title and body. Returns the note ID and creation timestamp. " +
"Use when you need to save text for later retrieval.",
inputSchema: z.object({
title: z.string().describe("Note title, max 200 characters"),
body: z.string().describe("Note body in plain text or Markdown"),
tags: z.array(z.string()).optional().describe("Optional tags for organisation"),
}),
outputSchema: z.object({
id: z.string(),
createdAt: z.string().datetime(),
}),
async handler(ctx, input) {
// Replace with your own data store:
const note = await db.notes.insert({
userId: ctx.userId,
title: input.title,
body: input.body,
tags: input.tags ?? [],
})
return { id: note.id, createdAt: note.createdAt.toISOString() }
},
})
const notesList = defineTool({
path: "notes.list",
name: "List Notes",
description:
"List all notes for the current user, optionally filtered by tag. " +
"Returns note IDs, titles, and tags. Use when you need to browse saved notes.",
inputSchema: z.object({
tag: z.string().optional().describe("Filter notes by this tag"),
limit: z.number().int().positive().default(20),
}),
outputSchema: z.object({
notes: z.array(
z.object({
id: z.string(),
title: z.string(),
tags: z.array(z.string()),
createdAt: z.string().datetime(),
}),
),
}),
async handler(ctx, input) {
// Replace with your own data store:
const notes = await db.notes.findMany({
userId: ctx.userId,
tag: input.tag,
limit: input.limit,
})
return {
notes: notes.map((n) => ({
id: n.id,
title: n.title,
tags: n.tags,
createdAt: n.createdAt.toISOString(),
})),
}
},
})
// --- Plugin ---
const plugin = definePlugin({
id: "notes",
name: "Notes",
description: "Create, list, and manage personal notes.",
authProviders: [],
tools: [notesCreate, notesList],
})
export default plugin
Register the plugin as a source
After building your plugin, register it with the Toolshed server so it appears in the tool catalog. Send a POST to /api/registry/sources with type: "plugin":
curl -X POST https://your-toolshed-server/api/registry/sources \
-H "Content-Type: application/json" \
-d '{
"type": "plugin",
"id": "notes-source",
"namespace": "notes",
"pluginId": "notes"
}'
| Field | Description |
|---|
type | Must be "plugin" for hand-written plugins |
id | Unique identifier for this source entry |
namespace | Prefix applied to tool paths in the catalog — should match your plugin’s id |
pluginId | The id from your definePlugin() call |
Once registered, Toolshed resolves the plugin, indexes all its tools, and makes them available for agents to discover and call through the MCP protocol.
Toolshed also supports auto-generating tools from OpenAPI specs, MCP servers, and GraphQL endpoints. See the source types in @toolshed/sdk (openapi, mcp, graphql) if you want to bring in an external API without writing handlers manually.