Skip to main content
Plugins are the hand-written layer of Toolshed. You define exactly which tools exist, what their schemas look like, and how they behave. The SDK exports two functions — definePlugin() and defineTool() — that give you full TypeScript inference from input schema to handler return type.

Installation

npm install @toolshed/sdk zod

Defining a tool

defineTool() is a thin typed wrapper around a ToolConfig object. It exists to give you inference and autocompletion when authoring a tool in isolation, before you pass it to definePlugin().
import { defineTool } from "@toolshed/sdk"
import { z } from "zod"

const issuesList = defineTool({
  path: "github.issues.list",
  name: "List Issues",
  description: "List issues in a GitHub repository, optionally filtered by state and labels.",
  inputSchema: z.object({
    owner: z.string().describe("Repository owner"),
    repo: z.string().describe("Repository name"),
    state: z.enum(["open", "closed", "all"]).default("open"),
    labels: z.array(z.string()).optional(),
    limit: z.number().int().positive().default(30),
  }),
  outputSchema: z.object({
    issues: z.array(
      z.object({
        number: z.number(),
        title: z.string(),
        state: z.string(),
        labels: z.array(z.string()),
        created_at: z.string(),
        url: z.string(),
      }),
    ),
  }),
  async handler(ctx, input) {
    const token = await ctx.auth.getToken("github")
    const res = await fetch(
      `https://api.github.com/repos/${input.owner}/${input.repo}/issues?state=${input.state}&per_page=${input.limit}`,
      { headers: { Authorization: `Bearer ${token}`, Accept: "application/vnd.github.v3+json" } },
    )
    const data = await res.json()
    return {
      issues: data.map((issue: Record<string, unknown>) => ({
        number: issue.number,
        title: issue.title,
        state: issue.state,
        labels: (issue.labels as Array<{ name: string }>).map((l) => l.name),
        created_at: issue.created_at,
        url: issue.html_url,
      })),
    }
  },
})

ToolConfig fields

FieldTypeRequiredDescription
pathstringYesDot-separated tool identifier, e.g. github.issues.list
namestringYesHuman-readable display name
descriptionstringYesWhat the tool does, when to use it, and what it returns
inputSchemaz.ZodTypeYesZod schema for the tool’s input
outputSchemaz.ZodTypeNoZod schema for the tool’s output
destructivebooleanNotrue if the tool modifies or deletes state (default false)
authProviderstringNoID of the auth provider this tool uses
serviceAccountAllowedbooleanNoWhether a service account may invoke this tool without a user token (default false)
handlerfunctionYesThe function that runs when the tool is invoked

Tool naming

Tool paths follow the namespace.resource.verb pattern. Every segment is lowercase with underscores for multi-word segments, and the full path must match ^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$.
github.issues.list     ✓
github.issues.create   ✓
github.repos.search    ✓

createIssue            ✗  (no namespace, camelCase)
github.Issues.List     ✗  (uppercase)
github_issues_list     ✗  (underscores instead of dots)
Use the most specific path that still reads naturally. github.issues.create is better than github.create_issue because it groups related tools under the same subtree, which policy patterns can then address with github.issues.*.

Tool annotations

Annotations are hints to MCP clients about how a tool behaves. Set them directly on the tool config:
Set destructive: true on any tool that deletes, overwrites, or sends data. MCP clients use this to decide whether to auto-approve invocations or prompt the user. Toolshed also uses it to decide whether the tool’s handler must call ctx.elicit() before proceeding.
const issuesCreate = defineTool({
  path: "github.issues.create",
  destructive: true,
  // ...
})
Tools without destructive: true are treated as read-only by default. MCP clients may auto-approve them. You don’t need to set any flag — the absence of destructive is enough.
Set serviceAccountAllowed: true if the tool can run on behalf of a service account rather than a specific user. This is useful for background jobs that don’t have an interactive user token.
const auditLogExport = defineTool({
  path: "audit.logs.export",
  serviceAccountAllowed: true,
  // ...
})

Defining a plugin

Once you have your tools, pass them to definePlugin() to produce a Plugin object you can register with the server.
import { definePlugin } from "@toolshed/sdk"

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],
})

export default plugin

PluginConfig fields

FieldTypeRequiredDescription
idstringYesStable unique identifier for the plugin
namestringYesHuman-readable plugin name
descriptionstringYesWhat the plugin provides
authProvidersAuthProvider[]YesAuth providers the plugin’s tools may use
toolsToolConfig[]YesThe tools this plugin exposes

Using PluginContext in handlers

Every handler receives a PluginContext as its first argument. It provides three capabilities:

ctx.auth.getToken()

Retrieve a user’s OAuth token or API key for a named provider. The provider ID must match one of the authProviders declared in your PluginConfig.
async handler(ctx, input) {
  const token = await ctx.auth.getToken("github")
  // use token in downstream API calls
}

ctx.elicit()

Suspend execution and ask the user to approve or provide additional input. Always call this before performing a destructive mutation.
const issuesCreate = defineTool({
  path: "github.issues.create",
  destructive: true,
  // ...
  async handler(ctx, input) {
    const approval = await ctx.elicit({
      toolPath: "github.issues.create",
      message: `Create issue "${input.title}" in ${input.owner}/${input.repo}?`,
      args: input,
      type: "approval",
    })
    if (!approval.approved) {
      throw new Error("Issue creation denied by user")
    }
    // proceed with the API call
  },
})
The type field accepts "approval" (yes/no confirmation) or "form" (structured data collection). The response object contains approved: boolean and an optional data map for form responses.
Never proceed with a destructive operation if approval.approved is false. Always check the response and throw or return early on denial.

ctx.logger

A structured logger scoped to the current tool execution.
async handler(ctx, input) {
  ctx.logger.info("Fetching issues", { repo: `${input.owner}/${input.repo}` })
  // ...
}

Complete example

The GitHub plugin below puts all of these pieces together. It defines three tools — two read-only and one destructive — and registers them under a single plugin with an OAuth2 provider.
import { definePlugin, defineTool } from "@toolshed/sdk"
import { z } from "zod"

const issuesList = defineTool({
  path: "github.issues.list",
  name: "List Issues",
  description: "List issues in a GitHub repository, optionally filtered by state and labels.",
  inputSchema: z.object({
    owner: z.string().describe("Repository owner"),
    repo: z.string().describe("Repository name"),
    state: z.enum(["open", "closed", "all"]).default("open"),
    labels: z.array(z.string()).optional(),
    limit: z.number().int().positive().default(30),
  }),
  outputSchema: z.object({
    issues: z.array(
      z.object({
        number: z.number(),
        title: z.string(),
        state: z.string(),
        labels: z.array(z.string()),
        created_at: z.string(),
        url: z.string(),
      }),
    ),
  }),
  async handler(ctx, input) {
    const token = await ctx.auth.getToken("github")
    const res = await fetch(
      `https://api.github.com/repos/${input.owner}/${input.repo}/issues?state=${input.state}&per_page=${input.limit}`,
      { headers: { Authorization: `Bearer ${token}`, Accept: "application/vnd.github.v3+json" } },
    )
    const data = await res.json()
    return {
      issues: data.map((issue: Record<string, unknown>) => ({
        number: issue.number,
        title: issue.title,
        state: issue.state,
        labels: (issue.labels as Array<{ name: string }>).map((l) => l.name),
        created_at: issue.created_at,
        url: issue.html_url,
      })),
    }
  },
})

const issuesCreate = defineTool({
  path: "github.issues.create",
  name: "Create Issue",
  description: "Create a new issue in a GitHub repository.",
  destructive: true,
  inputSchema: z.object({
    owner: z.string(),
    repo: z.string(),
    title: z.string(),
    body: z.string().optional(),
    labels: z.array(z.string()).optional(),
    assignees: z.array(z.string()).optional(),
  }),
  outputSchema: z.object({
    number: z.number(),
    url: z.string(),
  }),
  async handler(ctx, input) {
    const approval = await ctx.elicit({
      toolPath: "github.issues.create",
      message: `Create issue "${input.title}" in ${input.owner}/${input.repo}?`,
      args: input,
      type: "approval",
    })
    if (!approval.approved) {
      throw new Error("Issue creation denied by user")
    }

    const token = await ctx.auth.getToken("github")
    const res = await fetch(
      `https://api.github.com/repos/${input.owner}/${input.repo}/issues`,
      {
        method: "POST",
        headers: { Authorization: `Bearer ${token}`, Accept: "application/vnd.github.v3+json" },
        body: JSON.stringify({
          title: input.title,
          body: input.body,
          labels: input.labels,
          assignees: input.assignees,
        }),
      },
    )
    const data = await res.json()
    return { number: data.number, url: data.html_url }
  },
})

export default 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],
})