toolnexus 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +116 -0
- package/dist/adapters.d.ts +26 -0
- package/dist/adapters.js +24 -0
- package/dist/client.d.ts +169 -0
- package/dist/client.js +447 -0
- package/dist/http.d.ts +21 -0
- package/dist/http.js +90 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/mcp.d.ts +35 -0
- package/dist/mcp.js +169 -0
- package/dist/native.d.ts +21 -0
- package/dist/native.js +57 -0
- package/dist/skill.d.ts +16 -0
- package/dist/skill.js +184 -0
- package/dist/toolkit.d.ts +47 -0
- package/dist/toolkit.js +71 -0
- package/dist/types.d.ts +30 -0
- package/dist/types.js +5 -0
- package/package.json +29 -0
- package/src/adapters.ts +33 -0
- package/src/client.ts +495 -0
- package/src/http.ts +110 -0
- package/src/index.ts +8 -0
- package/src/mcp.ts +210 -0
- package/src/native.ts +72 -0
- package/src/skill.ts +195 -0
- package/src/toolkit.ts +93 -0
- package/src/types.ts +37 -0
package/src/mcp.ts
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dynamic MCP source. Mirrors opencode's mcp/index.ts + mcp/catalog.ts, but
|
|
3
|
+
* standalone (no Effect): connect to every configured server, list tools, and
|
|
4
|
+
* convert each into a uniform Tool.
|
|
5
|
+
*/
|
|
6
|
+
import { readFileSync } from "node:fs"
|
|
7
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
|
8
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
|
9
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
|
10
|
+
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
|
|
11
|
+
import type { Tool as McpToolDef } from "@modelcontextprotocol/sdk/types.js"
|
|
12
|
+
import { type JSONSchema, type Tool, type ToolContext, type ToolResult, type McpStatus, sanitize } from "./types.js"
|
|
13
|
+
|
|
14
|
+
const DEFAULT_TIMEOUT = 30_000
|
|
15
|
+
const MAX_LIST_PAGES = 1_000
|
|
16
|
+
|
|
17
|
+
export interface LocalServer {
|
|
18
|
+
type?: "local"
|
|
19
|
+
command: string[]
|
|
20
|
+
environment?: Record<string, string>
|
|
21
|
+
env?: Record<string, string>
|
|
22
|
+
cwd?: string
|
|
23
|
+
enabled?: boolean
|
|
24
|
+
disabled?: boolean
|
|
25
|
+
timeout?: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RemoteServer {
|
|
29
|
+
type?: "remote"
|
|
30
|
+
url: string
|
|
31
|
+
headers?: Record<string, string>
|
|
32
|
+
enabled?: boolean
|
|
33
|
+
disabled?: boolean
|
|
34
|
+
timeout?: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type ServerConfig = LocalServer | RemoteServer
|
|
38
|
+
export type McpConfig = Record<string, ServerConfig>
|
|
39
|
+
|
|
40
|
+
function isRemote(cfg: ServerConfig): cfg is RemoteServer {
|
|
41
|
+
if (cfg.type === "remote") return true
|
|
42
|
+
if (cfg.type === "local") return false
|
|
43
|
+
return "url" in cfg && typeof (cfg as RemoteServer).url === "string"
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isEnabled(cfg: ServerConfig): boolean {
|
|
47
|
+
if (cfg.disabled === true) return false
|
|
48
|
+
if (cfg.enabled === false) return false
|
|
49
|
+
return true
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Accept a path, a raw object, or an object wrapped under mcpServers/servers/mcp. */
|
|
53
|
+
export function parseMcpConfig(input: string | object): McpConfig {
|
|
54
|
+
const raw: any = typeof input === "string" ? JSON.parse(readFileSync(input, "utf8")) : input
|
|
55
|
+
const servers = raw.mcpServers ?? raw.servers ?? raw.mcp ?? raw
|
|
56
|
+
return servers as McpConfig
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Expand ${ENV_VAR} in header values from process.env so tokens live in the
|
|
61
|
+
* environment, not the committed config. Values are never logged.
|
|
62
|
+
*/
|
|
63
|
+
export function expandEnvHeaders(headers?: Record<string, string>): Record<string, string> | undefined {
|
|
64
|
+
if (!headers) return undefined
|
|
65
|
+
const out: Record<string, string> = {}
|
|
66
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
67
|
+
out[k] = v.replace(/\$\{([A-Za-z0-9_]+)\}/g, (_, name) => process.env[name] ?? "")
|
|
68
|
+
}
|
|
69
|
+
return out
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function formatToolErrorContent(content: unknown): string {
|
|
73
|
+
if (!Array.isArray(content)) return "MCP tool returned an error"
|
|
74
|
+
return (
|
|
75
|
+
content
|
|
76
|
+
.flatMap((item) => (isTextContent(item) ? [item.text] : []))
|
|
77
|
+
.filter((text) => text.trim())
|
|
78
|
+
.join("\n\n") || "MCP tool returned an error"
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isTextContent(value: unknown): value is { type: "text"; text: string } {
|
|
83
|
+
return (
|
|
84
|
+
typeof value === "object" &&
|
|
85
|
+
value !== null &&
|
|
86
|
+
(value as any).type === "text" &&
|
|
87
|
+
typeof (value as any).text === "string"
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function joinTextContent(content: unknown): string {
|
|
92
|
+
if (!Array.isArray(content)) return ""
|
|
93
|
+
return content
|
|
94
|
+
.flatMap((item) => (isTextContent(item) ? [item.text] : []))
|
|
95
|
+
.join("\n")
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function paginateTools(client: Client, timeout: number): Promise<McpToolDef[]> {
|
|
99
|
+
const result: McpToolDef[] = []
|
|
100
|
+
const cursors = new Set<string>()
|
|
101
|
+
let cursor: string | undefined
|
|
102
|
+
for (let page = 0; page < MAX_LIST_PAGES; page++) {
|
|
103
|
+
const res = await client.listTools(cursor === undefined ? undefined : { cursor }, { timeout })
|
|
104
|
+
result.push(...res.tools)
|
|
105
|
+
if (res.nextCursor === undefined) return result
|
|
106
|
+
if (cursors.has(res.nextCursor)) throw new Error(`MCP list returned duplicate cursor: ${res.nextCursor}`)
|
|
107
|
+
cursors.add(res.nextCursor)
|
|
108
|
+
cursor = res.nextCursor
|
|
109
|
+
}
|
|
110
|
+
throw new Error(`MCP list exceeded ${MAX_LIST_PAGES} pages`)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function convertTool(server: string, def: McpToolDef, client: Client, timeout: number): Tool {
|
|
114
|
+
const inputSchema: JSONSchema = {
|
|
115
|
+
...(def.inputSchema as object),
|
|
116
|
+
type: "object",
|
|
117
|
+
properties: (def.inputSchema?.properties ?? {}) as Record<string, unknown>,
|
|
118
|
+
additionalProperties: false,
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
name: sanitize(server) + "_" + sanitize(def.name),
|
|
122
|
+
description: def.description ?? "",
|
|
123
|
+
inputSchema,
|
|
124
|
+
source: "mcp",
|
|
125
|
+
async execute(args: Record<string, unknown>, ctx?: ToolContext): Promise<ToolResult> {
|
|
126
|
+
try {
|
|
127
|
+
const result: any = await client.callTool(
|
|
128
|
+
{ name: def.name, arguments: args ?? {} },
|
|
129
|
+
undefined,
|
|
130
|
+
{ resetTimeoutOnProgress: true, signal: ctx?.signal, timeout: ctx?.timeout ?? timeout },
|
|
131
|
+
)
|
|
132
|
+
if (result.isError) {
|
|
133
|
+
return { output: formatToolErrorContent(result.content), isError: true, metadata: { server } }
|
|
134
|
+
}
|
|
135
|
+
if (result.structuredContent !== undefined && result.structuredContent !== null) {
|
|
136
|
+
return { output: JSON.stringify(result.structuredContent), isError: false, metadata: { server } }
|
|
137
|
+
}
|
|
138
|
+
return { output: joinTextContent(result.content), isError: false, metadata: { server } }
|
|
139
|
+
} catch (e) {
|
|
140
|
+
return { output: e instanceof Error ? e.message : String(e), isError: true, metadata: { server } }
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface McpSource {
|
|
147
|
+
tools: Tool[]
|
|
148
|
+
status: Record<string, McpStatus>
|
|
149
|
+
close(): Promise<void>
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Connect to every enabled server, list + convert tools. Failures are isolated. */
|
|
153
|
+
export async function loadMcp(input: string | object): Promise<McpSource> {
|
|
154
|
+
const config = parseMcpConfig(input)
|
|
155
|
+
const tools: Tool[] = []
|
|
156
|
+
const status: Record<string, McpStatus> = {}
|
|
157
|
+
const clients: Client[] = []
|
|
158
|
+
|
|
159
|
+
await Promise.all(
|
|
160
|
+
Object.entries(config).map(async ([name, cfg]) => {
|
|
161
|
+
if (!isEnabled(cfg)) {
|
|
162
|
+
status[name] = "disabled"
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
const timeout = cfg.timeout ?? DEFAULT_TIMEOUT
|
|
166
|
+
const client = new Client({ name: "toolnexus", version: "0.1.0" }, { capabilities: {} })
|
|
167
|
+
try {
|
|
168
|
+
if (isRemote(cfg)) {
|
|
169
|
+
const url = new URL(cfg.url)
|
|
170
|
+
const expanded = expandEnvHeaders(cfg.headers)
|
|
171
|
+
const requestInit = expanded ? { headers: expanded } : undefined
|
|
172
|
+
try {
|
|
173
|
+
await client.connect(new StreamableHTTPClientTransport(url, { requestInit }))
|
|
174
|
+
} catch {
|
|
175
|
+
await client.connect(new SSEClientTransport(url, { requestInit }))
|
|
176
|
+
}
|
|
177
|
+
} else {
|
|
178
|
+
const local = cfg as LocalServer
|
|
179
|
+
const [cmd, ...args] = local.command
|
|
180
|
+
await client.connect(
|
|
181
|
+
new StdioClientTransport({
|
|
182
|
+
command: cmd,
|
|
183
|
+
args,
|
|
184
|
+
cwd: local.cwd,
|
|
185
|
+
env: { ...(process.env as Record<string, string>), ...(local.environment ?? local.env ?? {}) },
|
|
186
|
+
stderr: "pipe",
|
|
187
|
+
}),
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
const defs = await paginateTools(client, timeout)
|
|
191
|
+
for (const def of defs) tools.push(convertTool(name, def, client, timeout))
|
|
192
|
+
clients.push(client)
|
|
193
|
+
status[name] = "connected"
|
|
194
|
+
} catch (e) {
|
|
195
|
+
status[name] = "failed"
|
|
196
|
+
await client.close().catch(() => {})
|
|
197
|
+
// one bad server never breaks the toolkit
|
|
198
|
+
console.warn(`[toolnexus] MCP server "${name}" failed: ${e instanceof Error ? e.message : String(e)}`)
|
|
199
|
+
}
|
|
200
|
+
}),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
tools,
|
|
205
|
+
status,
|
|
206
|
+
async close() {
|
|
207
|
+
await Promise.all(clients.map((c) => c.close().catch(() => {})))
|
|
208
|
+
},
|
|
209
|
+
}
|
|
210
|
+
}
|
package/src/native.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native / annotation tools — turn a plain function into a uniform Tool.
|
|
3
|
+
* See ../../SPEC.md §6.
|
|
4
|
+
*/
|
|
5
|
+
import type { JSONSchema, Tool, ToolContext, ToolResult } from "./types.js"
|
|
6
|
+
|
|
7
|
+
export interface DefineToolOptions {
|
|
8
|
+
name: string
|
|
9
|
+
description: string
|
|
10
|
+
inputSchema?: JSONSchema
|
|
11
|
+
source?: Tool["source"]
|
|
12
|
+
/** Return a string (wrapped as output) or a full ToolResult. */
|
|
13
|
+
run: (args: Record<string, unknown>, ctx?: ToolContext) => unknown | Promise<unknown>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const EMPTY_SCHEMA: JSONSchema = { type: "object", properties: {}, additionalProperties: false }
|
|
17
|
+
|
|
18
|
+
/** Wrap a function as a Tool. The run() return value becomes the tool output. */
|
|
19
|
+
export function defineTool(opts: DefineToolOptions): Tool {
|
|
20
|
+
return {
|
|
21
|
+
name: opts.name,
|
|
22
|
+
description: opts.description,
|
|
23
|
+
inputSchema: opts.inputSchema ?? EMPTY_SCHEMA,
|
|
24
|
+
source: opts.source ?? "native",
|
|
25
|
+
async execute(args: Record<string, unknown>, ctx?: ToolContext): Promise<ToolResult> {
|
|
26
|
+
try {
|
|
27
|
+
const out = await opts.run(args ?? {}, ctx)
|
|
28
|
+
if (out && typeof out === "object" && "output" in (out as object) && "isError" in (out as object)) {
|
|
29
|
+
return out as ToolResult
|
|
30
|
+
}
|
|
31
|
+
return { output: typeof out === "string" ? out : JSON.stringify(out), isError: false }
|
|
32
|
+
} catch (e) {
|
|
33
|
+
return { output: e instanceof Error ? e.message : String(e), isError: true }
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Optional decorator sugar: `@tool("description", { name, inputSchema })`.
|
|
41
|
+
* Decorated methods/functions are collected; read them with `collectTools(obj)`.
|
|
42
|
+
*/
|
|
43
|
+
const REGISTRY = new WeakMap<object, Tool[]>()
|
|
44
|
+
|
|
45
|
+
export function tool(description: string, opts?: { name?: string; inputSchema?: JSONSchema }) {
|
|
46
|
+
return function (value: any, context: any) {
|
|
47
|
+
const fn = typeof value === "function" ? value : undefined
|
|
48
|
+
if (!fn) return value
|
|
49
|
+
const name = opts?.name ?? (context?.name ? String(context.name) : fn.name)
|
|
50
|
+
const t = defineTool({ name, description, inputSchema: opts?.inputSchema, run: (args, ctx) => fn(args, ctx) })
|
|
51
|
+
if (context?.addInitializer) {
|
|
52
|
+
context.addInitializer(function (this: object) {
|
|
53
|
+
const list = REGISTRY.get(this) ?? []
|
|
54
|
+
list.push(t)
|
|
55
|
+
REGISTRY.set(this, list)
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
;(fn as any).__tool = t
|
|
59
|
+
return value
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Gather tools defined with the @tool decorator on an instance or a plain object of fns. */
|
|
64
|
+
export function collectTools(obj: object): Tool[] {
|
|
65
|
+
const fromInstance = REGISTRY.get(obj) ?? []
|
|
66
|
+
const fromProps: Tool[] = []
|
|
67
|
+
for (const key of Object.keys(obj)) {
|
|
68
|
+
const v = (obj as any)[key]
|
|
69
|
+
if (typeof v === "function" && v.__tool) fromProps.push(v.__tool)
|
|
70
|
+
}
|
|
71
|
+
return [...fromInstance, ...fromProps]
|
|
72
|
+
}
|
package/src/skill.ts
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dynamic agent-skill source. Mirrors opencode's skill/index.ts + tool/skill.ts:
|
|
3
|
+
* discover **\/SKILL.md, parse frontmatter, and expose ONE `skill` tool that
|
|
4
|
+
* loads a skill's instructions + sampled resources on demand (progressive
|
|
5
|
+
* disclosure).
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync, readdirSync, statSync } from "node:fs"
|
|
8
|
+
import path from "node:path"
|
|
9
|
+
import { pathToFileURL } from "node:url"
|
|
10
|
+
import type { Tool, ToolResult } from "./types.js"
|
|
11
|
+
|
|
12
|
+
export const SKILL_TOOL_DESCRIPTION = `Load a specialized skill when the task at hand matches one of the skills listed in the system prompt.
|
|
13
|
+
|
|
14
|
+
Use this tool to inject the skill's instructions and resources into current conversation. The output may contain detailed workflow guidance as well as references to scripts, files, etc in the same directory as the skill.
|
|
15
|
+
|
|
16
|
+
The skill name must match one of the skills listed in your system prompt.`
|
|
17
|
+
|
|
18
|
+
export interface SkillInfo {
|
|
19
|
+
name: string
|
|
20
|
+
description?: string
|
|
21
|
+
location: string // absolute path to SKILL.md
|
|
22
|
+
content: string // body after frontmatter
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Minimal YAML frontmatter parser — only flat `key: value` pairs are needed. */
|
|
26
|
+
function parseFrontmatter(text: string): { data: Record<string, string>; content: string } {
|
|
27
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text)
|
|
28
|
+
if (!match) return { data: {}, content: text }
|
|
29
|
+
const data: Record<string, string> = {}
|
|
30
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
31
|
+
const idx = line.indexOf(":")
|
|
32
|
+
if (idx === -1) continue
|
|
33
|
+
const key = line.slice(0, idx).trim()
|
|
34
|
+
let value = line.slice(idx + 1).trim()
|
|
35
|
+
if (
|
|
36
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
37
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
38
|
+
) {
|
|
39
|
+
value = value.slice(1, -1)
|
|
40
|
+
}
|
|
41
|
+
if (key) data[key] = value
|
|
42
|
+
}
|
|
43
|
+
return { data, content: match[2] }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function walkSkillFiles(root: string): string[] {
|
|
47
|
+
const out: string[] = []
|
|
48
|
+
const stack = [root]
|
|
49
|
+
while (stack.length) {
|
|
50
|
+
const dir = stack.pop()!
|
|
51
|
+
let entries
|
|
52
|
+
try {
|
|
53
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
54
|
+
} catch {
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
57
|
+
for (const entry of entries) {
|
|
58
|
+
const full = path.join(dir, entry.name)
|
|
59
|
+
if (entry.isDirectory()) {
|
|
60
|
+
if (entry.name === "node_modules" || entry.name === ".git") continue
|
|
61
|
+
stack.push(full)
|
|
62
|
+
} else if (entry.isFile() && entry.name === "SKILL.md") {
|
|
63
|
+
out.push(full)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return out
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function sampleSiblingFiles(dir: string, limit = 10): string[] {
|
|
71
|
+
const out: string[] = []
|
|
72
|
+
const stack = [dir]
|
|
73
|
+
while (stack.length && out.length < limit) {
|
|
74
|
+
const cur = stack.pop()!
|
|
75
|
+
let entries
|
|
76
|
+
try {
|
|
77
|
+
entries = readdirSync(cur, { withFileTypes: true })
|
|
78
|
+
} catch {
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
for (const entry of entries) {
|
|
82
|
+
if (out.length >= limit) break
|
|
83
|
+
const full = path.join(cur, entry.name)
|
|
84
|
+
if (entry.isDirectory()) {
|
|
85
|
+
if (entry.name === "node_modules" || entry.name === ".git") continue
|
|
86
|
+
stack.push(full)
|
|
87
|
+
} else if (entry.isFile() && entry.name !== "SKILL.md") {
|
|
88
|
+
out.push(full)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return out
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface SkillSource {
|
|
96
|
+
skills: Record<string, SkillInfo>
|
|
97
|
+
tool: Tool
|
|
98
|
+
/** Markdown catalog for the system prompt (mirrors opencode Skill.fmt). */
|
|
99
|
+
prompt(): string
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Discover skills under one or more roots and build the `skill` loader tool. */
|
|
103
|
+
export function loadSkills(dirs: string | string[]): SkillSource {
|
|
104
|
+
const roots = Array.isArray(dirs) ? dirs : [dirs]
|
|
105
|
+
const skills: Record<string, SkillInfo> = {}
|
|
106
|
+
|
|
107
|
+
for (const root of roots) {
|
|
108
|
+
let isDir = false
|
|
109
|
+
try {
|
|
110
|
+
isDir = statSync(root).isDirectory()
|
|
111
|
+
} catch {
|
|
112
|
+
isDir = false
|
|
113
|
+
}
|
|
114
|
+
if (!isDir) {
|
|
115
|
+
console.warn(`[toolnexus] skills dir not found: ${root}`)
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
118
|
+
for (const file of walkSkillFiles(root)) {
|
|
119
|
+
let text: string
|
|
120
|
+
try {
|
|
121
|
+
text = readFileSync(file, "utf8")
|
|
122
|
+
} catch {
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
const { data, content } = parseFrontmatter(text)
|
|
126
|
+
if (!data.name) continue
|
|
127
|
+
if (skills[data.name]) {
|
|
128
|
+
console.warn(`[toolnexus] duplicate skill name "${data.name}" (${file}) — keeping first`)
|
|
129
|
+
continue
|
|
130
|
+
}
|
|
131
|
+
skills[data.name] = {
|
|
132
|
+
name: data.name,
|
|
133
|
+
description: data.description,
|
|
134
|
+
location: file,
|
|
135
|
+
content,
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const tool: Tool = {
|
|
141
|
+
name: "skill",
|
|
142
|
+
description: SKILL_TOOL_DESCRIPTION,
|
|
143
|
+
inputSchema: {
|
|
144
|
+
type: "object",
|
|
145
|
+
properties: { name: { type: "string", description: "The name of the skill to load" } },
|
|
146
|
+
required: ["name"],
|
|
147
|
+
additionalProperties: false,
|
|
148
|
+
},
|
|
149
|
+
source: "skill",
|
|
150
|
+
async execute(args: Record<string, unknown>): Promise<ToolResult> {
|
|
151
|
+
const name = String(args?.name ?? "")
|
|
152
|
+
const info = skills[name]
|
|
153
|
+
if (!info) {
|
|
154
|
+
return {
|
|
155
|
+
output: `Skill "${name}" not found. Available skills: ${Object.keys(skills).sort().join(", ") || "none"}`,
|
|
156
|
+
isError: true,
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const dir = path.dirname(info.location)
|
|
160
|
+
const base = pathToFileURL(dir).href
|
|
161
|
+
const files = sampleSiblingFiles(dir)
|
|
162
|
+
const output = [
|
|
163
|
+
`<skill_content name="${info.name}">`,
|
|
164
|
+
`# Skill: ${info.name}`,
|
|
165
|
+
"",
|
|
166
|
+
info.content.trim(),
|
|
167
|
+
"",
|
|
168
|
+
`Base directory for this skill: ${base}`,
|
|
169
|
+
"Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
|
|
170
|
+
"Note: file list is sampled.",
|
|
171
|
+
"",
|
|
172
|
+
"<skill_files>",
|
|
173
|
+
files.map((f) => `<file>${f}</file>`).join("\n"),
|
|
174
|
+
"</skill_files>",
|
|
175
|
+
"</skill_content>",
|
|
176
|
+
].join("\n")
|
|
177
|
+
return { output, isError: false, metadata: { name: info.name, dir } }
|
|
178
|
+
},
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
skills,
|
|
183
|
+
tool,
|
|
184
|
+
prompt() {
|
|
185
|
+
const described = Object.values(skills)
|
|
186
|
+
.filter((s) => s.description !== undefined)
|
|
187
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
188
|
+
if (described.length === 0) return "No skills are currently available."
|
|
189
|
+
return [
|
|
190
|
+
"## Available Skills",
|
|
191
|
+
...described.map((s) => `- **${s.name}**: ${s.description}`),
|
|
192
|
+
].join("\n")
|
|
193
|
+
},
|
|
194
|
+
}
|
|
195
|
+
}
|
package/src/toolkit.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Toolkit — aggregates dynamic MCP tools + the skill tool + custom tools into a
|
|
3
|
+
* single uniform list, with provider adapters and a single execute() router.
|
|
4
|
+
*/
|
|
5
|
+
import { type Tool, type ToolContext, type ToolResult, type McpStatus } from "./types.js"
|
|
6
|
+
import { loadMcp, type McpSource } from "./mcp.js"
|
|
7
|
+
import { loadSkills, type SkillSource } from "./skill.js"
|
|
8
|
+
import { toAnthropic, toGemini, toOpenAI } from "./adapters.js"
|
|
9
|
+
|
|
10
|
+
export interface ToolkitOptions {
|
|
11
|
+
mcpConfig?: string | object
|
|
12
|
+
skillsDir?: string | string[]
|
|
13
|
+
extraTools?: Tool[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class Toolkit {
|
|
17
|
+
private byName = new Map<string, Tool>()
|
|
18
|
+
private constructor(
|
|
19
|
+
private readonly mcp: McpSource | undefined,
|
|
20
|
+
private readonly skill: SkillSource | undefined,
|
|
21
|
+
extraTools: Tool[],
|
|
22
|
+
) {
|
|
23
|
+
const all = [...(mcp?.tools ?? []), ...(skill ? [skill.tool] : []), ...extraTools]
|
|
24
|
+
for (const t of all) {
|
|
25
|
+
if (this.byName.has(t.name)) {
|
|
26
|
+
console.warn(`[toolnexus] duplicate tool name "${t.name}" — keeping first`)
|
|
27
|
+
continue
|
|
28
|
+
}
|
|
29
|
+
this.byName.set(t.name, t)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
static async create(opts: ToolkitOptions): Promise<Toolkit> {
|
|
34
|
+
const mcp = opts.mcpConfig !== undefined ? await loadMcp(opts.mcpConfig) : undefined
|
|
35
|
+
const skill = opts.skillsDir !== undefined ? loadSkills(opts.skillsDir) : undefined
|
|
36
|
+
return new Toolkit(mcp, skill, opts.extraTools ?? [])
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
tools(): Tool[] {
|
|
40
|
+
return [...this.byName.values()]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
get(name: string): Tool | undefined {
|
|
44
|
+
return this.byName.get(name)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Add a tool at runtime (native, http, or any custom Tool). First name wins. */
|
|
48
|
+
register(...tools: Tool[]): this {
|
|
49
|
+
for (const t of tools) {
|
|
50
|
+
if (this.byName.has(t.name)) {
|
|
51
|
+
console.warn(`[toolnexus] duplicate tool name "${t.name}" — keeping first`)
|
|
52
|
+
continue
|
|
53
|
+
}
|
|
54
|
+
this.byName.set(t.name, t)
|
|
55
|
+
}
|
|
56
|
+
return this
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async execute(name: string, args: Record<string, unknown>, ctx?: ToolContext): Promise<ToolResult> {
|
|
60
|
+
const tool = this.byName.get(name)
|
|
61
|
+
if (!tool) {
|
|
62
|
+
return { output: `Unknown tool: ${name}`, isError: true }
|
|
63
|
+
}
|
|
64
|
+
return tool.execute(args ?? {}, ctx)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Markdown skill catalog for the system prompt. */
|
|
68
|
+
skillsPrompt(): string {
|
|
69
|
+
return this.skill ? this.skill.prompt() : ""
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
mcpStatus(): Record<string, McpStatus> {
|
|
73
|
+
return this.mcp?.status ?? {}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
toOpenAI() {
|
|
77
|
+
return toOpenAI(this.tools())
|
|
78
|
+
}
|
|
79
|
+
toAnthropic() {
|
|
80
|
+
return toAnthropic(this.tools())
|
|
81
|
+
}
|
|
82
|
+
toGemini() {
|
|
83
|
+
return toGemini(this.tools())
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async close(): Promise<void> {
|
|
87
|
+
await this.mcp?.close()
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function createToolkit(opts: ToolkitOptions): Promise<Toolkit> {
|
|
92
|
+
return Toolkit.create(opts)
|
|
93
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared contract types. See ../../SPEC.md.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export type JSONSchema = {
|
|
6
|
+
type: "object"
|
|
7
|
+
properties?: Record<string, unknown>
|
|
8
|
+
required?: string[]
|
|
9
|
+
additionalProperties?: boolean
|
|
10
|
+
[k: string]: unknown
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type ToolSource = "mcp" | "skill" | "native" | "http" | "custom"
|
|
14
|
+
|
|
15
|
+
export interface ToolResult {
|
|
16
|
+
output: string
|
|
17
|
+
isError: boolean
|
|
18
|
+
metadata?: Record<string, unknown>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ToolContext {
|
|
22
|
+
signal?: AbortSignal
|
|
23
|
+
timeout?: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface Tool {
|
|
27
|
+
name: string
|
|
28
|
+
description: string
|
|
29
|
+
inputSchema: JSONSchema
|
|
30
|
+
source: ToolSource
|
|
31
|
+
execute(args: Record<string, unknown>, ctx?: ToolContext): Promise<ToolResult>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type McpStatus = "connected" | "disabled" | "failed"
|
|
35
|
+
|
|
36
|
+
/** Replace anything outside [a-zA-Z0-9_-] with "_" (same as opencode). */
|
|
37
|
+
export const sanitize = (value: string) => value.replace(/[^a-zA-Z0-9_-]/g, "_")
|