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