Skip to content

Commit 9d2c715

Browse files
committed
fix: restore mcp tool
1 parent 7d3659e commit 9d2c715

File tree

3 files changed

+229
-69
lines changed

3 files changed

+229
-69
lines changed

src/api/kit.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,10 @@ global.sendResponse = (body: any, headers: Record<string, string> = {}) => {
318318
return global.sendWait(Channel.RESPONSE, response)
319319
}
320320

321+
// Import and export tool function
322+
import { tool } from './tool.js'
323+
global.tool = tool
324+
321325
let _consoleLog = global.console.log.bind(global.console)
322326
let _consoleWarn = global.console.warn.bind(global.console)
323327
let _consoleClear = global.console.clear.bind(global.console)

src/api/tool.ts

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import type { Tool } from "../types/globals"
2+
3+
// Store tool definitions for MCP registration
4+
export const toolDefinitions = new Map<string, Tool>()
5+
6+
// Helper function to get parameter names from Tool's inputSchema
7+
function getParameterNames(config: Tool): string[] {
8+
if (!config.inputSchema?.properties) return []
9+
return Object.keys(config.inputSchema.properties)
10+
}
11+
12+
// Helper function to get parameter info from inputSchema
13+
function getParameterInfo(config: Tool, name: string): any {
14+
if (!config.inputSchema?.properties) return {}
15+
return config.inputSchema.properties[name] || {}
16+
}
17+
18+
export async function tool<T = Record<string, any>>(
19+
config: Tool
20+
): Promise<T> {
21+
// Validate config
22+
if (!config.name) {
23+
throw new Error("Tool requires a name")
24+
}
25+
26+
// Register tool definition for MCP discovery
27+
toolDefinitions.set(config.name, config)
28+
29+
// Check if we're being called via MCP
30+
// First check headers (from HTTP server)
31+
if (global.headers && global.headers['X-MCP-Tool'] === config.name && global.headers['X-MCP-Parameters']) {
32+
try {
33+
const parameters = JSON.parse(global.headers['X-MCP-Parameters'])
34+
return parameters as T
35+
} catch (error) {
36+
// Ignore JSON parse errors
37+
}
38+
}
39+
40+
// Fallback: if all declared parameters are already present in global.headers
41+
// use them even when the sentinel keys are missing.
42+
const parameterNames = getParameterNames(config)
43+
if (
44+
global.headers &&
45+
!global.headers['X-MCP-Tool'] &&
46+
parameterNames.length > 0 &&
47+
parameterNames.every(k => k in global.headers)
48+
) {
49+
return global.headers as unknown as T;
50+
}
51+
52+
// Then check environment variable (for direct MCP calls)
53+
if (process.env.KIT_MCP_CALL) {
54+
try {
55+
const mcpCall = JSON.parse(process.env.KIT_MCP_CALL)
56+
if (mcpCall.tool === config.name) {
57+
// Return the parameters passed from MCP
58+
return mcpCall.parameters as T
59+
}
60+
} catch (error) {
61+
// Ignore JSON parse errors
62+
}
63+
}
64+
65+
// Check if parameters were passed via CLI args
66+
const cliParams = await parseCliParameters(config)
67+
if (cliParams) {
68+
return cliParams as T
69+
}
70+
71+
// Otherwise, prompt the user for parameters
72+
return await promptForParameters(config)
73+
}
74+
75+
async function parseCliParameters<T>(config: Tool): Promise<T | null> {
76+
const args = process.argv.slice(2)
77+
if (args.length === 0) return null
78+
79+
const params: any = {}
80+
let hasParams = false
81+
const parameterNames = getParameterNames(config)
82+
83+
// Parse flags
84+
for (let i = 0; i < args.length; i++) {
85+
if (args[i].startsWith("--")) {
86+
const key = args[i].slice(2)
87+
const nextArg = args[i + 1]
88+
89+
if (parameterNames.includes(key)) {
90+
hasParams = true
91+
const paramInfo = getParameterInfo(config, key)
92+
93+
switch (paramInfo.type) {
94+
case "boolean":
95+
params[key] = nextArg !== "false"
96+
if (nextArg !== "false" && nextArg !== "true") i--
97+
break
98+
case "number":
99+
params[key] = Number(nextArg)
100+
i++
101+
break
102+
case "array":
103+
params[key] = nextArg.split(",")
104+
i++
105+
break
106+
default:
107+
params[key] = nextArg
108+
i++
109+
}
110+
}
111+
}
112+
}
113+
114+
if (!hasParams) return null
115+
116+
// Apply defaults for missing parameters
117+
if (config.inputSchema?.properties) {
118+
for (const [key, schema] of Object.entries(config.inputSchema.properties)) {
119+
if (!(key in params) && (schema as any).default !== undefined) {
120+
params[key] = (schema as any).default
121+
}
122+
}
123+
}
124+
125+
return params as T
126+
}
127+
128+
async function promptForParameters<T>(config: Tool): Promise<T> {
129+
const result: any = {}
130+
131+
if (!config.inputSchema?.properties) {
132+
return result as T
133+
}
134+
135+
const requiredParams = config.inputSchema.required || []
136+
137+
// Prompt for each parameter
138+
for (const [name, schema] of Object.entries(config.inputSchema.properties)) {
139+
const paramInfo = schema as any
140+
141+
if (paramInfo.type === "string" && paramInfo.enum) {
142+
// Use select for enums
143+
result[name] = await global.arg({
144+
placeholder: paramInfo.description || `Select ${name}`,
145+
choices: paramInfo.enum.map(value => ({ name: String(value), value }))
146+
})
147+
} else if (paramInfo.type === "number") {
148+
// Use number input
149+
const value = await global.arg({
150+
placeholder: paramInfo.description || `Enter ${name}`,
151+
type: "text" // Will validate as number
152+
})
153+
result[name] = Number(value)
154+
} else if (paramInfo.type === "boolean") {
155+
// Use toggle or select
156+
result[name] = await global.arg({
157+
placeholder: paramInfo.description || `${name}?`,
158+
choices: [
159+
{ name: "Yes", value: true },
160+
{ name: "No", value: false }
161+
]
162+
})
163+
} else if (paramInfo.type === "array") {
164+
// For arrays, prompt for comma-separated values
165+
const value = await global.arg({
166+
placeholder: paramInfo.description || `Enter ${name} (comma-separated)`
167+
})
168+
result[name] = value.split(",").map(v => v.trim())
169+
} else {
170+
// Default text input
171+
result[name] = await global.arg({
172+
placeholder: paramInfo.description || `Enter ${name}`
173+
})
174+
}
175+
176+
// Apply defaults if no value provided
177+
if (result[name] === undefined && paramInfo.default !== undefined) {
178+
result[name] = paramInfo.default
179+
}
180+
}
181+
182+
// Validate required parameters
183+
for (const name of requiredParams) {
184+
if (result[name] === undefined || result[name] === null || result[name] === "") {
185+
throw new Error(`Required parameter '${name}' is missing`)
186+
}
187+
}
188+
189+
return result as T
190+
}
191+

src/types/globals.d.ts

Lines changed: 34 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types"
2+
export type { CallToolResult, Tool }
3+
14
type ReadFileOptions = Parameters<typeof import('node:fs/promises').readFile>[1]
25

36
export type EnsureReadFile = (path: string, defaultContent?: string, options?: ReadFileOptions) => Promise<string>
@@ -318,74 +321,7 @@ declare global {
318321
var globby: typeof import('globby').globby
319322
}
320323

321-
// MCP (Model Context Protocol) Types
322-
export interface MCPToolResult {
323-
/**
324-
* A list of content objects that represent the result of the tool call.
325-
* This field is always present, but may be empty.
326-
*/
327-
content: Array<
328-
| {
329-
type: 'text'
330-
/**
331-
* The text content of the message.
332-
*/
333-
text: string
334-
}
335-
| {
336-
type: 'image'
337-
/**
338-
* The base64-encoded image data.
339-
*/
340-
data: string
341-
/**
342-
* The MIME type of the image. Different providers may support different image types.
343-
*/
344-
mimeType: string
345-
}
346-
| {
347-
type: 'audio'
348-
/**
349-
* The base64-encoded audio data.
350-
*/
351-
data: string
352-
/**
353-
* The MIME type of the audio. Different providers may support different audio types.
354-
*/
355-
mimeType: string
356-
}
357-
| {
358-
type: 'resource'
359-
resource: {
360-
/**
361-
* The URI of this resource.
362-
*/
363-
uri: string
364-
/**
365-
* The MIME type of this resource, if known.
366-
*/
367-
mimeType?: string
368-
} & (
369-
| {
370-
/**
371-
* The text of the item. This must only be set if the item can actually be represented as text (not binary data).
372-
*/
373-
text: string
374-
}
375-
| {
376-
/**
377-
* A base64-encoded string representing the binary data of the item.
378-
*/
379-
blob: string
380-
}
381-
)
382-
}
383-
>
384-
/**
385-
* This result property is reserved by the protocol to allow clients and servers to attach additional metadata to their responses.
386-
*/
387-
_meta?: Record<string, any>
388-
}
324+
389325

390326
declare global {
391327
/**
@@ -408,5 +344,34 @@ declare global {
408344
* export default result
409345
* ```
410346
*/
411-
type MCPToolResult = import('./globals').MCPToolResult
347+
type MCPToolResult = typeof CallToolResult
348+
349+
/**
350+
* Define a tool that can be used via MCP, CLI, or Script Kit UI
351+
* Returns the parameters when called, similar to arg()
352+
* @example
353+
* ```ts
354+
* const { operation, a, b } = await tool({
355+
* name: "calculator",
356+
* description: "Perform calculations",
357+
* inputSchema: {
358+
* type: "object",
359+
* properties: {
360+
* operation: {
361+
* type: "string",
362+
* enum: ["add", "subtract", "multiply", "divide"],
363+
* description: "The operation to perform"
364+
* },
365+
* a: { type: "number", description: "First number" },
366+
* b: { type: "number", description: "Second number" }
367+
* },
368+
* required: ["operation", "a", "b"]
369+
* }
370+
* })
371+
*
372+
* const result = operation === "add" ? a + b : a - b
373+
* await sendResponse({ result })
374+
* ```
375+
*/
376+
var tool: <T = Record<string, any>>(toolConfig: Tool) => Promise<T>
412377
}

0 commit comments

Comments
 (0)