diff --git a/package.json b/package.json index d90e0b5..72533d5 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "@anthropic-ai/mcpb": "^1.1.1", "@biomejs/biome": "2.3.10", "@modelcontextprotocol/sdk": "^1.25.3", - "@smithery/api": "^0.66.0", + "@smithery/api": "^0.67.0", "@smithery/sdk": "^4.1.0", "@types/inquirer": "^8.2.4", "@types/inquirer-autocomplete-prompt": "^3.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 17a44a0..e6f50c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,8 +18,8 @@ importers: specifier: ^1.25.3 version: 1.25.3(hono@4.11.1)(zod@4.2.1) '@smithery/api': - specifier: ^0.66.0 - version: 0.66.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.1)(zod@4.2.1)) + specifier: ^0.67.0 + version: 0.67.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.1)(zod@4.2.1)) '@smithery/sdk': specifier: ^4.1.0 version: 4.1.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.1)(zod@4.2.1))(zod@4.2.1) @@ -806,8 +806,8 @@ packages: resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} engines: {node: '>=18'} - '@smithery/api@0.66.0': - resolution: {integrity: sha512-atE6NjMF6kg8cSjaeu8o9zA/6zF0uUtjSDJEdYRvT357f3WEldm+jcE1lSea59dl7r0m1OPAhrprxLD/zQU+aQ==} + '@smithery/api@0.67.0': + resolution: {integrity: sha512-ELI0Ig8Q212bQC7kmlEPdJc0vrd5E6UFJiiFC5Uf2i5YEaMRSCIlYjPw+oY3MiupDn6StAS6YBOfEtdVNSwvBw==} peerDependencies: '@modelcontextprotocol/sdk': '>=1.0.0' peerDependenciesMeta: @@ -2671,7 +2671,7 @@ snapshots: '@sindresorhus/is@7.2.0': {} - '@smithery/api@0.66.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.1)(zod@4.2.1))': + '@smithery/api@0.67.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.1)(zod@4.2.1))': optionalDependencies: '@modelcontextprotocol/sdk': 1.25.3(hono@4.11.1)(zod@4.2.1) diff --git a/src/commands/__tests__/mcp-add-source.test.ts b/src/commands/__tests__/mcp-add-source.test.ts new file mode 100644 index 0000000..5b6293d --- /dev/null +++ b/src/commands/__tests__/mcp-add-source.test.ts @@ -0,0 +1,210 @@ +import { mkdir, rm, writeFile } from "node:fs/promises" +import path from "node:path" +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest" + +const { + mockCreateConnection, + mockSetConnection, + mockCreateSession, + mockOutputConnectionDetail, +} = vi.hoisted(() => { + const createConnection = vi.fn() + const setConnection = vi.fn() + const createSession = vi.fn(async () => ({ + createConnection, + setConnection, + })) + + return { + mockCreateConnection: createConnection, + mockSetConnection: setConnection, + mockCreateSession: createSession, + mockOutputConnectionDetail: vi.fn(), + } +}) + +vi.mock("../mcp/api", () => ({ + ConnectSession: { + create: mockCreateSession, + }, + connectionTargetFromInput: (input: string) => + input.startsWith("http://") || input.startsWith("https://") + ? { mcpUrl: input } + : { server: input }, +})) + +vi.mock("../mcp/output-connection", () => ({ + outputConnectionDetail: mockOutputConnectionDetail, +})) + +import { addServer } from "../mcp/add" + +describe("mcp add --source", () => { + let cwd: string + let consoleErrorSpy: ReturnType + + beforeEach(async () => { + vi.clearAllMocks() + consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + cwd = path.join(process.cwd(), ".context", "mcp-add-source") + await rm(cwd, { recursive: true, force: true }) + await mkdir(cwd, { recursive: true }) + await writeFile( + path.join(cwd, "support.ts"), + "export function normalize(input: { text: string }): { text: string } { return input }\n", + ) + }) + + afterEach(async () => { + consoleErrorSpy.mockRestore() + await rm(cwd, { recursive: true, force: true }) + }) + + test("sets a source-backed connection when an id is provided", async () => { + mockSetConnection.mockResolvedValue({ + connectionId: "support", + name: "Support tools", + mcpUrl: "https://dynamic-mcp-module.smithery.internal/calclavia/support", + metadata: { team: "support" }, + status: { state: "connected" }, + }) + + await addServer(undefined, { + id: "support", + name: "Support tools", + namespace: "calclavia", + metadata: '{"team":"support"}', + source: path.join(".context", "mcp-add-source", "support.ts"), + }) + + expect(mockCreateSession).toHaveBeenCalledWith("calclavia") + expect(mockSetConnection).toHaveBeenCalledWith( + "support", + { + source: { + kind: "module", + entrypoint: ".context/mcp-add-source/support.ts", + sourceFiles: [ + { + path: ".context/mcp-add-source/support.ts", + contents: + "export function normalize(input: { text: string }): { text: string } { return input }\n", + }, + ], + }, + }, + { + name: "Support tools", + metadata: { team: "support" }, + }, + ) + expect(mockCreateConnection).not.toHaveBeenCalled() + expect(mockOutputConnectionDetail).toHaveBeenCalledWith( + expect.objectContaining({ + connection: expect.objectContaining({ connectionId: "support" }), + tip: "Use smithery tool list support to view tools.", + }), + ) + }) + + test("creates a source-backed connection when no id is provided", async () => { + mockCreateConnection.mockResolvedValue({ + connectionId: "support-tools", + name: "Support tools", + mcpUrl: + "https://dynamic-mcp-module.smithery.internal/calclavia/support-tools", + metadata: null, + status: { state: "connected" }, + }) + + await addServer(undefined, { + name: "Support tools", + source: path.join(".context", "mcp-add-source", "support.ts"), + }) + + expect(mockCreateConnection).toHaveBeenCalledWith( + expect.objectContaining({ + source: expect.objectContaining({ + kind: "module", + entrypoint: ".context/mcp-add-source/support.ts", + }), + }), + { + name: "Support tools", + metadata: undefined, + }, + ) + expect(mockSetConnection).not.toHaveBeenCalled() + }) + + test("defaults source-backed connection name to id", async () => { + mockSetConnection.mockResolvedValue({ + connectionId: "support", + name: "support", + mcpUrl: "https://dynamic-mcp-module.smithery.internal/calclavia/support", + metadata: null, + status: { state: "connected" }, + }) + + await addServer(undefined, { + id: "support", + source: path.join(".context", "mcp-add-source", "support.ts"), + }) + + expect(mockSetConnection).toHaveBeenCalledWith( + "support", + expect.any(Object), + expect.objectContaining({ + name: "support", + }), + ) + }) + + test("rejects unsupported source option combinations", async () => { + await expect( + addServer("github", { + source: path.join(".context", "mcp-add-source", "support.ts"), + }), + ).rejects.toThrow("process.exit() was called") + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining( + "--source cannot be used with a server argument.", + ), + ) + expect(mockCreateConnection).not.toHaveBeenCalled() + expect(mockSetConnection).not.toHaveBeenCalled() + }) + + test.each([ + { + options: { headers: '{"x-api-key":"secret"}' }, + message: "--headers is not supported for source-backed connections.", + }, + { + options: { config: "{}" }, + message: "--config is not supported for source-backed connections.", + }, + { + options: { force: true }, + message: "--force is not supported for source-backed connections.", + }, + { + options: { uplinkCommand: ["node", "server.js"] }, + message: "--source cannot be used with a local command.", + }, + ])("rejects $message", async ({ options, message }) => { + await expect( + addServer(undefined, { + ...options, + source: path.join(".context", "mcp-add-source", "support.ts"), + }), + ).rejects.toThrow("process.exit() was called") + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining(message), + ) + expect(mockCreateConnection).not.toHaveBeenCalled() + expect(mockSetConnection).not.toHaveBeenCalled() + }) +}) diff --git a/src/commands/__tests__/mcp-api.test.ts b/src/commands/__tests__/mcp-api.test.ts index 9d3f210..c2c5ece 100644 --- a/src/commands/__tests__/mcp-api.test.ts +++ b/src/commands/__tests__/mcp-api.test.ts @@ -1,8 +1,12 @@ import { ConflictError } from "@smithery/api" -import { describe, expect, test, vi } from "vitest" +import { afterEach, describe, expect, test, vi } from "vitest" import { ConnectSession } from "../mcp/api" describe("ConnectSession uplink compatibility", () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + test("creates uplink connections without an mcpUrl", async () => { const create = vi.fn().mockResolvedValue({ connectionId: "local-dev", @@ -48,6 +52,32 @@ describe("ConnectSession uplink compatibility", () => { }) }) + test("creates source-backed connections with module source targets", async () => { + const source = { + kind: "module" as const, + entrypoint: "support.ts", + sourceFiles: [{ path: "support.ts", contents: "export {}\n" }], + } + const create = vi.fn().mockResolvedValue({ + connectionId: "support", + name: "Support tools", + mcpUrl: "https://dynamic-mcp-module.smithery.internal/calclavia/support", + metadata: null, + status: { state: "connected" }, + }) + + const session = new ConnectSession( + { connections: { create } } as never, + "calclavia", + ) + await session.createConnection({ source }, { name: "Support tools" }) + + expect(create).toHaveBeenCalledWith("calclavia", { + source, + name: "Support tools", + }) + }) + test("does not replace conflicting uplink connections on 409", async () => { const conflict = new ConflictError(409, {}, undefined, new Headers()) const set = vi.fn().mockRejectedValueOnce(conflict) @@ -70,6 +100,31 @@ describe("ConnectSession uplink compatibility", () => { expect(del).not.toHaveBeenCalled() }) + test("does not replace conflicting source-backed connections on 409", async () => { + const conflict = new ConflictError(409, {}, undefined, new Headers()) + const set = vi.fn().mockRejectedValueOnce(conflict) + const del = vi.fn().mockResolvedValue({ success: true }) + const source = { + kind: "module" as const, + entrypoint: "support.ts", + sourceFiles: [{ path: "support.ts", contents: "export {}\n" }], + } + + const session = new ConnectSession( + { connections: { set, delete: del } } as never, + "calclavia", + ) + await expect(session.setConnection("support", { source })).rejects.toBe( + conflict, + ) + + expect(set).toHaveBeenCalledWith("support", { + namespace: "calclavia", + source, + }) + expect(del).not.toHaveBeenCalled() + }) + test("retries conflicting http set requests after deleting the connection", async () => { const set = vi .fn() @@ -121,6 +176,37 @@ describe("ConnectSession uplink compatibility", () => { }) }) + test("sets source-backed connections with module source targets", async () => { + const source = { + kind: "module" as const, + entrypoint: "support.ts", + sourceFiles: [{ path: "support.ts", contents: "export {}\n" }], + } + const set = vi.fn().mockResolvedValue({ + connectionId: "support", + name: "Support tools", + mcpUrl: "https://dynamic-mcp-module.smithery.internal/calclavia/support", + metadata: null, + status: { state: "connected" }, + }) + + const session = new ConnectSession( + { connections: { set } } as never, + "calclavia", + ) + await session.setConnection( + "support", + { source }, + { name: "Support tools" }, + ) + + expect(set).toHaveBeenCalledWith("support", { + namespace: "calclavia", + source, + name: "Support tools", + }) + }) + test("lists tools over smithery.run REST", async () => { const get = vi.fn().mockResolvedValue({ tools: [ @@ -152,6 +238,62 @@ describe("ConnectSession uplink compatibility", () => { ]) }) + test("lists dynamic module tools over the MCP endpoint", async () => { + const fetch = vi.fn().mockResolvedValue({ + ok: true, + text: async () => + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + result: { + tools: [ + { + name: "proof_ping", + description: "proof_ping", + inputSchema: { type: "object" }, + }, + ], + }, + }), + }) + vi.stubGlobal("fetch", fetch) + const session = new ConnectSession( + { apiKey: "smry_test" } as never, + "calclavia", + ) + + const tools = await session.listToolsForConnection({ + connectionId: "codex-pipe-proof", + name: "Codex Pipe Proof", + mcpUrl: + "https://dynamic-mcp-module.smithery.internal/calclavia/codex-pipe-proof", + } as never) + + expect(fetch).toHaveBeenCalledWith( + "https://mcp.smithery.run/calclavia/codex-pipe-proof", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + authorization: "Bearer smry_test", + }), + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tools/list", + }), + }), + ) + expect(tools).toEqual([ + { + connectionId: "codex-pipe-proof", + connectionName: "Codex Pipe Proof", + name: "proof_ping", + description: "proof_ping", + inputSchema: { type: "object" }, + }, + ]) + }) + test("calls dotted tools through hierarchical REST paths", async () => { const getConnection = vi.fn().mockResolvedValue({ connectionId: "github", @@ -176,4 +318,60 @@ describe("ConnectSession uplink compatibility", () => { defaultBaseURL: "https://smithery.run", }) }) + + test("calls dynamic module tools over the MCP endpoint", async () => { + const getConnection = vi.fn().mockResolvedValue({ + connectionId: "codex-pipe-proof", + name: "Codex Pipe Proof", + mcpUrl: + "https://dynamic-mcp-module.smithery.internal/calclavia/codex-pipe-proof", + status: { state: "connected" }, + }) + const fetch = vi.fn().mockResolvedValue({ + ok: true, + text: async () => + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + result: { + structuredContent: { ok: "true" }, + }, + }), + }) + vi.stubGlobal("fetch", fetch) + const session = new ConnectSession( + { + apiKey: "smry_test", + connections: { get: getConnection }, + } as never, + "calclavia", + ) + + const result = await session.callTool("codex-pipe-proof", "proof_ping", { + echo: "pipe invocation", + }) + + expect(getConnection).toHaveBeenCalledWith("codex-pipe-proof", { + namespace: "calclavia", + }) + expect(fetch).toHaveBeenCalledWith( + "https://mcp.smithery.run/calclavia/codex-pipe-proof", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + authorization: "Bearer smry_test", + }), + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "proof_ping", + arguments: { echo: "pipe invocation" }, + }, + }), + }), + ) + expect(result).toEqual({ structuredContent: { ok: "true" } }) + }) }) diff --git a/src/commands/__tests__/mcp-source.test.ts b/src/commands/__tests__/mcp-source.test.ts new file mode 100644 index 0000000..9d47c8b --- /dev/null +++ b/src/commands/__tests__/mcp-source.test.ts @@ -0,0 +1,140 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" +import { afterEach, beforeEach, describe, expect, test } from "vitest" +import { loadDynamicMcpModuleSource } from "../mcp/source" + +describe("loadDynamicMcpModuleSource", () => { + let cwd: string + let cleanupPaths: string[] + + beforeEach(async () => { + const parent = path.join(process.cwd(), ".context") + await mkdir(parent, { recursive: true }) + cwd = await mkdtemp(path.join(parent, "dynamic-source-")) + cleanupPaths = [cwd] + }) + + afterEach(async () => { + await Promise.all( + cleanupPaths.map((cleanupPath) => + rm(cleanupPath, { recursive: true, force: true }), + ), + ) + }) + + test("loads a TypeScript entrypoint as a module source body", async () => { + await mkdir(path.join(cwd, "src")) + await writeFile( + path.join(cwd, "src", "support.ts"), + "export function normalize(input: { text: string }): { text: string } { return input }\n", + ) + + const source = await loadDynamicMcpModuleSource("src/support.ts", cwd) + + expect(source).toEqual({ + kind: "module", + entrypoint: "src/support.ts", + sourceFiles: [ + { + path: "src/support.ts", + contents: + "export function normalize(input: { text: string }): { text: string } { return input }\n", + }, + ], + }) + }) + + test("loads piped TypeScript source from stdin", async () => { + const source = await loadDynamicMcpModuleSource( + "-", + cwd, + async () => + "export function normalize(input: { text: string }): { text: string } { return input }\n", + ) + + expect(source).toEqual({ + kind: "module", + entrypoint: "stdin.ts", + sourceFiles: [ + { + path: "stdin.ts", + contents: + "export function normalize(input: { text: string }): { text: string } { return input }\n", + }, + ], + }) + }) + + test("rejects unsupported extensions", async () => { + await writeFile(path.join(cwd, "support.js"), "export {}\n") + + await expect(loadDynamicMcpModuleSource("support.js", cwd)).rejects.toThrow( + "Source file must end in .ts, .tsx, .mts, or .cts.", + ) + }) + + test("rejects missing files", async () => { + await expect(loadDynamicMcpModuleSource("missing.ts", cwd)).rejects.toThrow( + "Source file not found: missing.ts", + ) + }) + + test("rejects directories", async () => { + await mkdir(path.join(cwd, "src")) + + await expect(loadDynamicMcpModuleSource("src", cwd)).rejects.toThrow( + "Source path must be a file: src", + ) + }) + + test("rejects relative imports because source submission is entrypoint-only", async () => { + await writeFile( + path.join(cwd, "support.ts"), + 'import { normalize } from "./normalize"\nexport { normalize }\n', + ) + + await expect(loadDynamicMcpModuleSource("support.ts", cwd)).rejects.toThrow( + "Source file imports ./normalize; --source currently supports a single entrypoint file only.", + ) + }) + + test("rejects relative imports from stdin", async () => { + await expect( + loadDynamicMcpModuleSource( + "-", + cwd, + async () => 'import { normalize } from "./normalize"\n', + ), + ).rejects.toThrow( + "Source file imports ./normalize; --source currently supports a single entrypoint file only.", + ) + }) + + test("rejects files over the server source file size limit", async () => { + await writeFile(path.join(cwd, "large.ts"), "a".repeat(128 * 1024 + 1)) + + await expect(loadDynamicMcpModuleSource("large.ts", cwd)).rejects.toThrow( + "Source file must be 128KB or smaller.", + ) + }) + + test("rejects piped source over the server source file size limit", async () => { + await expect( + loadDynamicMcpModuleSource("-", cwd, async () => + "a".repeat(128 * 1024 + 1), + ), + ).rejects.toThrow("Source file must be 128KB or smaller.") + }) + + test("rejects files outside the current working directory", async () => { + const outsideDir = await mkdtemp(path.join(tmpdir(), "dynamic-source-")) + cleanupPaths.push(outsideDir) + const outsideFile = path.join(outsideDir, "outside.ts") + await writeFile(outsideFile, "export {}\n") + + await expect(loadDynamicMcpModuleSource(outsideFile, cwd)).rejects.toThrow( + "Source file must be inside the current working directory.", + ) + }) +}) diff --git a/src/commands/__tests__/uplink-target.test.ts b/src/commands/__tests__/uplink-target.test.ts index 1d08d99..94ded67 100644 --- a/src/commands/__tests__/uplink-target.test.ts +++ b/src/commands/__tests__/uplink-target.test.ts @@ -35,6 +35,22 @@ describe("extractAddInvocation", () => { }) }) + test("does not treat hidden --source value as a server operand", () => { + const result = extractAddInvocation([ + "node", + "smithery", + "mcp", + "add", + "--source", + "src/support.ts", + ]) + + expect(result).toEqual({ + server: undefined, + commandTokens: [], + }) + }) + test("extracts a stdio command passed after -- without inventing a server", () => { const result = extractAddInvocation([ "node", diff --git a/src/commands/mcp/add.ts b/src/commands/mcp/add.ts index 70ce222..100d143 100644 --- a/src/commands/mcp/add.ts +++ b/src/commands/mcp/add.ts @@ -15,6 +15,7 @@ import { import { ConnectSession, connectionTargetFromInput } from "./api" import { outputConnectionDetail } from "./output-connection" import { parseJsonObject } from "./parse-json" +import { loadDynamicMcpModuleSource } from "./source" import { classifyAddTarget } from "./uplink-target" interface AddServerOptions { @@ -25,6 +26,7 @@ interface AddServerOptions { headers?: string config?: string force?: boolean + source?: string uplinkCommand?: string[] } @@ -32,6 +34,10 @@ export async function addServer( server: string | undefined, options: AddServerOptions, ): Promise { + if (options.source) { + return addSourceServer(server, options) + } + const target = await classifyAddTarget({ server, commandTokens: options.uplinkCommand, @@ -97,6 +103,64 @@ export async function addServer( return addServerImpl(mcpUrl, { ...options, name }) } +async function addSourceServer( + server: string | undefined, + options: AddServerOptions, +): Promise { + try { + if (!options.source) { + throw new Error("--source requires a TypeScript source file.") + } + if (server) { + throw new Error("--source cannot be used with a server argument.") + } + if (options.uplinkCommand && options.uplinkCommand.length > 0) { + throw new Error("--source cannot be used with a local command.") + } + if (options.headers !== undefined) { + throw new Error( + "--headers is not supported for source-backed connections.", + ) + } + if (options.config !== undefined) { + throw new Error( + "--config is not supported for source-backed connections.", + ) + } + if (options.force) { + throw new Error("--force is not supported for source-backed connections.") + } + + const parsedMetadata = parseJsonObject(options.metadata, "Metadata") + const source = await loadDynamicMcpModuleSource(options.source) + const session = await ConnectSession.create(options.namespace) + const name = options.name ?? options.id + const connection = options.id + ? await session.setConnection( + options.id, + { source }, + { + name, + metadata: parsedMetadata, + }, + ) + : await session.createConnection( + { source }, + { + name, + metadata: parsedMetadata, + }, + ) + + outputConnectionDetail({ + connection, + tip: `Use smithery tool list ${connection.connectionId} to view tools.`, + }) + } catch (error) { + fatal("Failed to add source-backed connection", error) + } +} + function isHttpUrl(value: string): boolean { return value.startsWith("http://") || value.startsWith("https://") } diff --git a/src/commands/mcp/api.ts b/src/commands/mcp/api.ts index b2de77b..22c0b3a 100644 --- a/src/commands/mcp/api.ts +++ b/src/commands/mcp/api.ts @@ -16,8 +16,18 @@ import { export type { Connection, ConnectionsListResponse } export type ConnectionTarget = - | (Required> & { server?: never }) - | (Required> & { mcpUrl?: never }) + | (Required> & { + server?: never + source?: never + }) + | (Required> & { + mcpUrl?: never + source?: never + }) + | (Required> & { + mcpUrl?: never + server?: never + }) export interface Trigger { name: string @@ -54,6 +64,8 @@ type ConnectionsListQuery = ConnectionListParams & Record<`metadata.${string}`, string> const SMITHERY_RUN_BASE_URL = "https://smithery.run" +const SMITHERY_MCP_BASE_URL = "https://mcp.smithery.run" +const DYNAMIC_MCP_MODULE_ORIGIN = "https://dynamic-mcp-module.smithery.internal" /** * Session for Connect operations that reuses clients within a command. @@ -129,6 +141,18 @@ export class ConnectSession { async listToolsForConnection(connection: Connection): Promise { throwIfAuthRequired(connection) + if (isDynamicMcpModuleConnection(connection)) { + const result = await this.callDynamicMcpMethod<{ tools?: Tool[] }>( + connection.connectionId, + "tools/list", + ) + return (result.tools ?? []).map((tool) => ({ + ...tool, + connectionId: connection.connectionId, + connectionName: connection.name, + })) + } + const result = await this.smitheryClient.get<{ tools?: Tool[] }>( toolCollectionPath(this.namespace, connection.connectionId), { @@ -147,7 +171,15 @@ export class ConnectSession { toolName: string, args: Record, ): Promise { - throwIfAuthRequired(await this.getConnection(connectionId)) + const connection = await this.getConnection(connectionId) + throwIfAuthRequired(connection) + + if (isDynamicMcpModuleConnection(connection)) { + return this.callDynamicMcpMethod(connectionId, "tools/call", { + name: toolName, + arguments: args, + }) + } return this.smitheryClient.post( toolItemPath(this.namespace, connectionId, toolName), @@ -158,6 +190,42 @@ export class ConnectSession { ) } + private async callDynamicMcpMethod( + connectionId: string, + method: string, + params?: Record, + ): Promise { + const response = await fetch(dynamicMcpUrl(this.namespace, connectionId), { + method: "POST", + headers: { + authorization: `Bearer ${this.smitheryClient.apiKey}`, + "content-type": "application/json", + accept: "application/json, text/event-stream", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method, + ...(params ? { params } : {}), + }), + }) + const bodyText = await response.text() + if (!response.ok) { + throw new Error( + `Dynamic MCP request failed (${response.status}): ${bodyText}`, + ) + } + + const body = JSON.parse(bodyText) as { + result?: T + error?: { message?: string } + } + if (body.error) { + throw new Error(body.error.message ?? "Dynamic MCP request failed") + } + return body.result as T + } + async createConnection( target?: string | ConnectionTarget, options: ConnectionWriteOptions = {}, @@ -183,7 +251,11 @@ export class ConnectSession { buildConnectionSetParams(this.namespace, target, options), ) } catch (error) { - if (error instanceof ConflictError && options.transport !== "uplink") { + if ( + error instanceof ConflictError && + options.transport !== "uplink" && + !isSourceTarget(target) + ) { await this.deleteConnection(connectionId) return this.smitheryClient.connections.set( connectionId, @@ -288,12 +360,26 @@ function buildConnectionSetParams( function normalizeConnectionTarget( target: string | ConnectionTarget | undefined, -): Pick { +): Pick { if (!target) return {} if (typeof target === "string") return { mcpUrl: target } return target } +function isSourceTarget( + target: string | ConnectionTarget | undefined, +): target is Required> { + return typeof target === "object" && target !== null && "source" in target +} + +function isDynamicMcpModuleConnection(connection: Connection): boolean { + return connection.mcpUrl?.startsWith(DYNAMIC_MCP_MODULE_ORIGIN) === true +} + +function dynamicMcpUrl(namespace: string, connectionId: string): string { + return `${SMITHERY_MCP_BASE_URL}/${encodeURIComponent(namespace)}/${encodeURIComponent(connectionId)}` +} + function isHttpUrl(value: string): boolean { return value.startsWith("http://") || value.startsWith("https://") } diff --git a/src/commands/mcp/source.ts b/src/commands/mcp/source.ts new file mode 100644 index 0000000..d62c22d --- /dev/null +++ b/src/commands/mcp/source.ts @@ -0,0 +1,108 @@ +import { readFile, realpath, stat } from "node:fs/promises" +import path from "node:path" +import type { ConnectionCreateParams } from "@smithery/api/resources/connections.js" + +const SOURCE_EXTENSIONS = new Set([".ts", ".tsx", ".mts", ".cts"]) +const MAX_SOURCE_FILE_BYTES = 128 * 1024 +const STDIN_SOURCE_PATH = "stdin.ts" +const RELATIVE_IMPORT_PATTERN = + /\b(?:import|export)\s+(?:type\s+)?(?:[^'"]*?\s+from\s+)?["'](\.{1,2}\/[^"']+)["']|\bimport\s*\(\s*["'](\.{1,2}\/[^"']+)["']\s*\)/g + +export type DynamicMcpModuleSource = NonNullable< + ConnectionCreateParams["source"] +> + +export async function loadDynamicMcpModuleSource( + sourcePath: string, + cwd = process.cwd(), + readStdin = readStdinSource, +): Promise { + if (sourcePath === "-") { + if (readStdin === readStdinSource && process.stdin.isTTY) { + throw new Error("--source - requires TypeScript source on stdin.") + } + const contents = await readStdin() + return buildModuleSource(STDIN_SOURCE_PATH, contents) + } + + const absolutePath = path.resolve(cwd, sourcePath) + const [realCwd, realSourcePath] = await Promise.all([ + realpath(cwd), + realpath(absolutePath).catch((error: unknown) => { + throw new Error(`Source file not found: ${sourcePath}`, { cause: error }) + }), + ]) + + if (!isPathInside(realSourcePath, realCwd)) { + throw new Error("Source file must be inside the current working directory.") + } + + const sourceStat = await stat(realSourcePath) + if (!sourceStat.isFile()) { + throw new Error(`Source path must be a file: ${sourcePath}`) + } + if (sourceStat.size > MAX_SOURCE_FILE_BYTES) { + throw new Error("Source file must be 128KB or smaller.") + } + + if (!SOURCE_EXTENSIONS.has(path.extname(realSourcePath))) { + throw new Error("Source file must end in .ts, .tsx, .mts, or .cts.") + } + + const relativePath = toPosixPath(path.relative(realCwd, realSourcePath)) + const contents = await readFile(realSourcePath, "utf8") + return buildModuleSource(relativePath, contents) +} + +function buildModuleSource( + entrypoint: string, + contents: string, +): DynamicMcpModuleSource { + if (Buffer.byteLength(contents, "utf8") > MAX_SOURCE_FILE_BYTES) { + throw new Error("Source file must be 128KB or smaller.") + } + + const relativeImport = findRelativeImport(contents) + if (relativeImport) { + throw new Error( + `Source file imports ${relativeImport}; --source currently supports a single entrypoint file only.`, + ) + } + + return { + kind: "module", + entrypoint, + sourceFiles: [ + { + path: entrypoint, + contents, + }, + ], + } +} + +async function readStdinSource(): Promise { + const chunks: Buffer[] = [] + for await (const chunk of process.stdin) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + } + return Buffer.concat(chunks).toString("utf8") +} + +function isPathInside(filePath: string, rootPath: string): boolean { + const relative = path.relative(rootPath, filePath) + return ( + relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative) + ) +} + +function toPosixPath(value: string): string { + return value.split(path.sep).join(path.posix.sep) +} + +function findRelativeImport(contents: string): string | undefined { + for (const match of contents.matchAll(RELATIVE_IMPORT_PATTERN)) { + return match[1] ?? match[2] + } + return undefined +} diff --git a/src/commands/mcp/uplink-target.ts b/src/commands/mcp/uplink-target.ts index 469edf8..6fe4754 100644 --- a/src/commands/mcp/uplink-target.ts +++ b/src/commands/mcp/uplink-target.ts @@ -31,6 +31,7 @@ const OPTIONS_WITH_VALUES = new Set([ "--metadata", "--headers", "--namespace", + "--source", "-c", "--client", "--config", diff --git a/src/index.ts b/src/index.ts index a6139f5..e24290d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,7 +6,7 @@ import pc from "picocolors" const brandOrange = (text: string) => `\x1b[38;2;234;88;12m${text}\x1b[39m` -import { Command } from "commander" +import { Command, Option } from "commander" import { z } from "zod" import { ConstraintSchema, constraintJsonSchema } from "./commands/auth/token" import { SKILL_AGENTS } from "./config/agents" @@ -39,6 +39,7 @@ interface CliOptions { organization?: string metadata?: string headers?: string + source?: string agent?: string global?: boolean yes?: boolean @@ -353,6 +354,9 @@ async function handleMcpAdd( invocation.commandTokens.length > 0 ? invocation.server : server if (options.client) { + if (options.source) { + fatal("--source is not supported with --client.") + } if (invocation.commandTokens.length > 0) { fatal("Local commands passed after -- are not supported with --client.") } @@ -620,6 +624,12 @@ mcpCmd .option("--name ", "Human-readable name for the server") .option("--metadata ", "Custom metadata as JSON object") .option("--headers ", "Custom headers as JSON object (stored securely)") + .addOption( + new Option( + "--source ", + "TypeScript module source file for a dynamic MCP", + ).hideHelp(), + ) .option("--namespace ", "Target namespace") .option( "--force", diff --git a/src/lib/__tests__/errors.test.ts b/src/lib/__tests__/errors.test.ts new file mode 100644 index 0000000..a4cf263 --- /dev/null +++ b/src/lib/__tests__/errors.test.ts @@ -0,0 +1,41 @@ +import { BadRequestError } from "@smithery/api" +import { describe, expect, test } from "vitest" +import { createError } from "../errors" + +describe("createError", () => { + test("formats invalid module diagnostics from API bad request errors", () => { + const error = new BadRequestError( + 400, + { + error: { + code: "invalid_module", + message: "The submitted module could not be installed.", + diagnostics: [ + { + path: "support.ts", + line: 3, + column: 10, + severity: "error", + message: + "Exported tool normalize must declare an explicit return type.", + }, + ], + }, + }, + undefined, + new Headers(), + ) + + const formatted = createError( + error, + "Failed to add source-backed connection", + ) + + expect(formatted.message).toContain( + "Failed to add source-backed connection: The submitted module could not be installed.", + ) + expect(formatted.message).toContain( + "support.ts:3:10 Exported tool normalize must declare an explicit return type.", + ) + }) +}) diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 2f3c06f..16cccb5 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -61,6 +61,10 @@ export function createError(error: unknown, context: string): Error { return new Error(`${context}: ${errorMessage}`, { cause: error }) } if (error instanceof BadRequestError) { + const invalidModuleMessage = formatInvalidModuleError(error.error) + if (invalidModuleMessage) { + return new Error(`${context}: ${invalidModuleMessage}`, { cause: error }) + } const errorMessage = getErrorMessage(error, "Invalid request") return new Error(`${context}: ${errorMessage}`, { cause: error }) } @@ -72,3 +76,45 @@ export function createError(error: unknown, context: string): Error { const errorMessage = getErrorMessage(error, context) return new Error(errorMessage) } + +function formatInvalidModuleError(errorBody: unknown): string | undefined { + if (!isRecord(errorBody) || !isRecord(errorBody.error)) { + return undefined + } + const payload = errorBody.error + if (payload.code !== "invalid_module") { + return undefined + } + + const message = + typeof payload.message === "string" + ? payload.message + : "The submitted module could not be installed." + const diagnostics = Array.isArray(payload.diagnostics) + ? payload.diagnostics + .map(formatDynamicModuleDiagnostic) + .filter((diagnostic) => diagnostic.length > 0) + : [] + + if (diagnostics.length === 0) { + return message + } + + return `${message}\n${diagnostics.join("\n")}` +} + +function formatDynamicModuleDiagnostic(value: unknown): string { + if (!isRecord(value)) { + return "" + } + const path = typeof value.path === "string" ? value.path : "" + const line = typeof value.line === "number" ? value.line : 1 + const column = typeof value.column === "number" ? value.column : 1 + const message = + typeof value.message === "string" ? value.message : "Invalid module source." + return `${path}:${line}:${column} ${message}` +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null +}