Advanced
Build a custom tool
Ship an MCP server exposing your tool — starts at 10 lines.
Two packaging paths
Custom tools ship two ways: an in-process TypeScript bundle dropped into .kition/tools/ and loaded by the built-in runtime at launch, or a standalone MCP server (any language) exposed over stdio / SSE. Embed when the tool is small and pure; reach for MCP when you need state, long-lived connections, or reuse across vaults.
Both look identical to the agent — same tool schema surface, same permission model, same hook interception. The only real difference is distribution: embedded tools travel with the vault, MCP servers travel with the host.
From a UX standpoint, embedded tools are the shortest path to "write once, use immediately". MCP is the right answer when a tool needs to be shared across vaults, machines, or even teams.
Embedded tool skeleton
Save the file below as .kition/tools/word-count.ts. Kition scans, type-checks and registers it on launch. The default export must be defineTool({...}) — Kition uses the schema for argument validation and run for execution.
The ctx parameter exposes the logger, vault filesystem, session metadata, and secret store — you never need to require Kition internals directly.
import { defineTool } from '@kition/tools'
import { z } from 'zod'
export default defineTool({
name: 'word_count',
title: 'Word count',
description: 'Count words in a string, optionally splitting on punctuation.',
input: z.object({
text: z.string().min(1),
splitOnPunctuation: z.boolean().default(false),
}),
output: z.object({
words: z.number(),
chars: z.number(),
}),
async run({ input, ctx }) {
const re = input.splitOnPunctuation ? /[\s\p{P}]+/u : /\s+/u
const words = input.text.trim().split(re).filter(Boolean).length
ctx.log.info('counted', { words })
return { words, chars: input.text.length }
},
})Reaching vault and secrets
Non-trivial tools usually need to read vault notes, call external APIs, or persist state. The ctx object surfaces all of that — file I/O via ctx.vault, secrets via ctx.secrets (Keychain / DPAPI / libsecret), HTTP via ctx.fetch (auto-routed and auto-retried).
import { defineTool } from '@kition/tools'
import { z } from 'zod'
export default defineTool({
name: 'jira_create_issue',
title: 'Create Jira issue',
description: 'Create a Jira issue from the current note.',
input: z.object({
project: z.string(),
summary: z.string(),
notePath: z.string(),
}),
async run({ input, ctx }) {
const body = await ctx.vault.readFile(input.notePath)
const token = await ctx.secrets.get('jira.api_token')
const res = await ctx.fetch('https://acme.atlassian.net/rest/api/3/issue', {
method: 'POST',
headers: {
authorization: `Basic ${token}`,
'content-type': 'application/json',
},
body: JSON.stringify({
fields: {
project: { key: input.project },
summary: input.summary,
description: body,
issuetype: { name: 'Task' },
},
}),
})
if (!res.ok) throw new Error(`jira ${res.status}`)
const json = await res.json() as { key: string }
return { key: json.key, url: `https://acme.atlassian.net/browse/${json.key}` }
},
})Standalone MCP server
Reach for MCP when the tool needs external APIs, a database connection, or a language other than TypeScript. Any server that speaks the Model Context Protocol can be mounted. The minimal TypeScript example below wires up the two core handlers, tools/list and tools/call.
For production we recommend stdio transport — fast startup, strong isolation, no port to manage. SSE makes sense when the server runs remotely (e.g. an internal tools service).
import { Server } from '@modelcontextprotocol/sdk/server'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio'
const server = new Server({ name: 'word-count', version: '0.1.0' })
server.setRequestHandler('tools/list', () => ({
tools: [{
name: 'word_count',
description: 'Count words in a string',
inputSchema: {
type: 'object',
properties: { text: { type: 'string' } },
required: ['text'],
},
}],
}))
server.setRequestHandler('tools/call', async (req) => {
const text = req.params.arguments.text as string
const words = text.trim().split(/\s+/).filter(Boolean).length
return { content: [{ type: 'text', text: String(words) }] }
})
await server.connect(new StdioServerTransport())Register with Kition
Embedded tools need no config — drop the file in and it loads. MCP servers need a stdio entry in .kition/mcp.json; restart the agent and the tool shows up in the /tools listing.
Want everyone on the team running the same toolset? Commit .kition/mcp.json to the vault’s git repo — pulling is enough, the whole config is declarative.
{
"servers": {
"word-count": {
"type": "stdio",
"command": "node",
"args": ["./scripts/word-count-server.js"],
"env": { "LOG_LEVEL": "info" }
},
"internal-tools": {
"type": "sse",
"url": "https://tools.internal.acme.com/mcp",
"headers": { "authorization": "Bearer ${env:INTERNAL_TOOLS_TOKEN}" }
}
}
}Permissions and hooks
- Tools default to "ask" — first invocation prompts the user; switch to allow / deny in settings
- Use
permissions.tools[name]for fine-grained control (e.g.network.fetchscoped to a domain) - Hooks can fire on
pre_tool_use/post_tool_usefor compliance, audit, redaction - Throw or return non-zero on failure — the agent sees the full stack trace
- Every invocation is logged to
.kition/audit.jsonlwith input, output, and duration
Debugging tips
kition tools listprints what is loaded for the current vault and where it came fromkition tools call word_count --json '{"text":"hi there"}'invokes the tool directly, bypassing the agent- MCP server stderr lands in
~/Library/Logs/Kition/mcp-*.log - Schema mismatches show as clear zod / ajv errors at boot, not silent skips
- Sprinkle
ctx.log.debug(...)inside tools — surface withKITION_LOG_LEVEL=debug