flamecast 0.0.1
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/.turbo/turbo-build.log +4 -0
- package/README.md +15 -0
- package/package.json +37 -0
- package/src/cache/index.ts +12 -0
- package/src/cache/local-file-storage.ts +72 -0
- package/src/cache/memory-cache.ts +33 -0
- package/src/cache/types.ts +55 -0
- package/src/cache/utils.ts +44 -0
- package/src/errors.ts +10 -0
- package/src/index.ts +4 -0
- package/src/runner.ts +440 -0
- package/src/types.ts +59 -0
- package/src/utils.ts +43 -0
- package/tsconfig.json +9 -0
package/README.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# runner-example
|
|
2
|
+
|
|
3
|
+
To install dependencies:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
bun install
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
To run:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
bun run index.ts
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
This project was created using `bun init` in bun v1.1.10. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "flamecast",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"main": "./dist/index.js",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsc",
|
|
18
|
+
"check-types": "tsc --noEmit",
|
|
19
|
+
"check": "biome check",
|
|
20
|
+
"fmt": "biome check --write --unsafe"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/node": "latest",
|
|
24
|
+
"tsx": "^4.21.0",
|
|
25
|
+
"@smithery/typescript-config": "workspace:*"
|
|
26
|
+
},
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"typescript": "^5.0.0"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@modelcontextprotocol/sdk": "^1.25.2",
|
|
32
|
+
"@smithery/runner": "workspace:*",
|
|
33
|
+
"ai": "^6.0.3",
|
|
34
|
+
"dotenv": "^17.2.3",
|
|
35
|
+
"zod": "^4.1.13"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
CacheAdapter,
|
|
3
|
+
CachedResolution,
|
|
4
|
+
GenerateCacheKeyFn,
|
|
5
|
+
} from "./types.js"
|
|
6
|
+
export { MemoryCache } from "./memory-cache.js"
|
|
7
|
+
export { LocalFileStorage } from "./local-file-storage.js"
|
|
8
|
+
export {
|
|
9
|
+
generateCacheKey,
|
|
10
|
+
generateToolSchemaHash,
|
|
11
|
+
normalizeServerIdentifier,
|
|
12
|
+
} from "./utils.js"
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { existsSync, mkdirSync } from "node:fs"
|
|
2
|
+
import { readdir, readFile, unlink, writeFile } from "node:fs/promises"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
import type { CacheAdapter, CachedResolution } from "./types.js"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* File-based cache adapter storing resolutions in a local directory.
|
|
8
|
+
* Each cached resolution is stored as a JSON file named by its key.
|
|
9
|
+
*
|
|
10
|
+
* Default directory: .smithery/ in the current working directory.
|
|
11
|
+
*/
|
|
12
|
+
export class LocalFileStorage implements CacheAdapter {
|
|
13
|
+
private readonly cacheDir: string
|
|
14
|
+
|
|
15
|
+
constructor(cacheDir?: string) {
|
|
16
|
+
this.cacheDir = cacheDir ?? path.join(process.cwd(), ".smithery")
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
private ensureDir(): void {
|
|
20
|
+
if (!existsSync(this.cacheDir)) {
|
|
21
|
+
mkdirSync(this.cacheDir, { recursive: true })
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
private getFilePath(key: string): string {
|
|
26
|
+
return path.join(this.cacheDir, `${key}.json`)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async get(key: string): Promise<CachedResolution | null> {
|
|
30
|
+
const filePath = this.getFilePath(key)
|
|
31
|
+
|
|
32
|
+
if (!existsSync(filePath)) {
|
|
33
|
+
return null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const content = await readFile(filePath, "utf8")
|
|
38
|
+
return JSON.parse(content) as CachedResolution
|
|
39
|
+
} catch {
|
|
40
|
+
// File exists but is invalid - treat as cache miss
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async set(key: string, value: CachedResolution): Promise<void> {
|
|
46
|
+
this.ensureDir()
|
|
47
|
+
const filePath = this.getFilePath(key)
|
|
48
|
+
await writeFile(filePath, JSON.stringify(value, null, 2))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async delete(key: string): Promise<void> {
|
|
52
|
+
const filePath = this.getFilePath(key)
|
|
53
|
+
|
|
54
|
+
if (existsSync(filePath)) {
|
|
55
|
+
await unlink(filePath)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async clear(): Promise<void> {
|
|
60
|
+
if (!existsSync(this.cacheDir)) {
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const files = await readdir(this.cacheDir, { withFileTypes: true })
|
|
65
|
+
|
|
66
|
+
await Promise.all(
|
|
67
|
+
files
|
|
68
|
+
.filter(file => file.isFile() && file.name.endsWith(".json"))
|
|
69
|
+
.map(file => unlink(path.join(this.cacheDir, file.name))),
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { CacheAdapter, CachedResolution } from "./types.js"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* In-memory cache adapter using a Map.
|
|
5
|
+
* Useful for development, testing, or short-lived processes.
|
|
6
|
+
* Data is lost when the process terminates.
|
|
7
|
+
*/
|
|
8
|
+
export class MemoryCache implements CacheAdapter {
|
|
9
|
+
private cache: Map<string, CachedResolution> = new Map()
|
|
10
|
+
|
|
11
|
+
async get(key: string): Promise<CachedResolution | null> {
|
|
12
|
+
return this.cache.get(key) ?? null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async set(key: string, value: CachedResolution): Promise<void> {
|
|
16
|
+
this.cache.set(key, value)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async delete(key: string): Promise<void> {
|
|
20
|
+
this.cache.delete(key)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async clear(): Promise<void> {
|
|
24
|
+
this.cache.clear()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Get the number of cached entries (for debugging)
|
|
29
|
+
*/
|
|
30
|
+
get size(): number {
|
|
31
|
+
return this.cache.size
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A cached resolution of an intent to a tool call
|
|
3
|
+
*/
|
|
4
|
+
export interface CachedResolution {
|
|
5
|
+
/** Cache key (hash of intent + servers) */
|
|
6
|
+
key: string
|
|
7
|
+
|
|
8
|
+
/** Resolved server ID */
|
|
9
|
+
serverId: string
|
|
10
|
+
|
|
11
|
+
/** Resolved server qualified name */
|
|
12
|
+
serverQualifiedName: string
|
|
13
|
+
|
|
14
|
+
/** Resolved tool name */
|
|
15
|
+
toolName: string
|
|
16
|
+
|
|
17
|
+
/** Extracted parameters */
|
|
18
|
+
params: Record<string, unknown>
|
|
19
|
+
|
|
20
|
+
/** ISO timestamp of when this was resolved */
|
|
21
|
+
resolvedAt: string
|
|
22
|
+
|
|
23
|
+
/** Hash of the tool's input schema (for cache invalidation) */
|
|
24
|
+
toolSchemaHash: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Interface for pluggable cache implementations
|
|
29
|
+
*/
|
|
30
|
+
export interface CacheAdapter {
|
|
31
|
+
/**
|
|
32
|
+
* Get a cached resolution by key
|
|
33
|
+
*/
|
|
34
|
+
get(key: string): Promise<CachedResolution | null>
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Store a resolution in the cache
|
|
38
|
+
*/
|
|
39
|
+
set(key: string, value: CachedResolution): Promise<void>
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Delete a specific cache entry
|
|
43
|
+
*/
|
|
44
|
+
delete(key: string): Promise<void>
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Clear all cached entries
|
|
48
|
+
*/
|
|
49
|
+
clear(): Promise<void>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Function type for custom cache key generation
|
|
54
|
+
*/
|
|
55
|
+
export type GenerateCacheKeyFn = (prompt: string, servers: string[]) => string
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import crypto from "node:crypto"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generates a deterministic cache key from a prompt and list of servers.
|
|
5
|
+
* Uses SHA-256 hash for a stable, filesystem-safe key.
|
|
6
|
+
*/
|
|
7
|
+
export function generateCacheKey(prompt: string, servers: string[]): string {
|
|
8
|
+
const normalizedPrompt = prompt.trim().toLowerCase()
|
|
9
|
+
const sortedServers = [...servers].sort()
|
|
10
|
+
const input = JSON.stringify({
|
|
11
|
+
prompt: normalizedPrompt,
|
|
12
|
+
servers: sortedServers,
|
|
13
|
+
})
|
|
14
|
+
return crypto.createHash("sha256").update(input).digest("hex")
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Generates a hash of tool schemas for cache invalidation.
|
|
19
|
+
* If schemas change, the hash changes and cache is invalidated.
|
|
20
|
+
*/
|
|
21
|
+
export function generateToolSchemaHash(
|
|
22
|
+
tools: Array<{ name: string; inputSchema: unknown }>,
|
|
23
|
+
): string {
|
|
24
|
+
const sortedTools = [...tools].sort((a, b) => a.name.localeCompare(b.name))
|
|
25
|
+
const schemaData = sortedTools.map(t => ({
|
|
26
|
+
name: t.name,
|
|
27
|
+
schema: t.inputSchema,
|
|
28
|
+
}))
|
|
29
|
+
return crypto
|
|
30
|
+
.createHash("sha256")
|
|
31
|
+
.update(JSON.stringify(schemaData))
|
|
32
|
+
.digest("hex")
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Normalizes a server identifier for consistent cache key generation.
|
|
37
|
+
* Removes protocol and trailing slashes.
|
|
38
|
+
*/
|
|
39
|
+
export function normalizeServerIdentifier(server: string): string {
|
|
40
|
+
return server
|
|
41
|
+
.replace(/^https?:\/\//, "")
|
|
42
|
+
.replace(/\/+$/, "")
|
|
43
|
+
.toLowerCase()
|
|
44
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export class MCPAuthenticationRequiredError extends Error {
|
|
2
|
+
constructor(
|
|
3
|
+
message: string,
|
|
4
|
+
public readonly authorizationUrl: string,
|
|
5
|
+
) {
|
|
6
|
+
super(`\n\n⚠️ ⚠️ ⚠️ ⚠️ ⚠️\n${message}\n\n${authorizationUrl}\n⚠️ ⚠️ ⚠️ ⚠️ ⚠️\n\n`)
|
|
7
|
+
this.name = "MCPAuthenticationRequiredError"
|
|
8
|
+
this.authorizationUrl = authorizationUrl
|
|
9
|
+
}
|
|
10
|
+
}
|
package/src/index.ts
ADDED
package/src/runner.ts
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
import {
|
|
2
|
+
addServerToProfile,
|
|
3
|
+
callTool,
|
|
4
|
+
exchangeApiKeyForToken,
|
|
5
|
+
listTools,
|
|
6
|
+
} from "@smithery/runner"
|
|
7
|
+
import type { Tool } from "@modelcontextprotocol/sdk/types.js"
|
|
8
|
+
import { generateText, jsonSchema, Output, stepCountIs, tool } from "ai"
|
|
9
|
+
import type { z } from "zod"
|
|
10
|
+
|
|
11
|
+
import type {
|
|
12
|
+
CacheAdapter,
|
|
13
|
+
CachedResolution,
|
|
14
|
+
GenerateCacheKeyFn,
|
|
15
|
+
} from "./cache/types.js"
|
|
16
|
+
import {
|
|
17
|
+
generateCacheKey as defaultGenerateCacheKey,
|
|
18
|
+
generateToolSchemaHash,
|
|
19
|
+
normalizeServerIdentifier,
|
|
20
|
+
} from "./cache/utils.js"
|
|
21
|
+
import { MCPAuthenticationRequiredError } from "./errors.js"
|
|
22
|
+
import {
|
|
23
|
+
type ActOptions,
|
|
24
|
+
type ConnectionConfig,
|
|
25
|
+
defaultSchema,
|
|
26
|
+
emptyInputSchema,
|
|
27
|
+
type OnAuthenticationRequiredCallback,
|
|
28
|
+
type PlanOptions,
|
|
29
|
+
type PollResult,
|
|
30
|
+
type RunnerInitOptions,
|
|
31
|
+
type RunOptions,
|
|
32
|
+
type ServerInput,
|
|
33
|
+
type ToolCallTemplate,
|
|
34
|
+
} from "./types.js"
|
|
35
|
+
import { fillTemplate, getSchemaKeys } from "./utils.js"
|
|
36
|
+
|
|
37
|
+
export class Runner {
|
|
38
|
+
private readonly serviceToken: string
|
|
39
|
+
private readonly profileSlug: string
|
|
40
|
+
private serverConfigMap: Map<string, string> = new Map()
|
|
41
|
+
private onAuthenticationRequired?: OnAuthenticationRequiredCallback
|
|
42
|
+
private cache?: CacheAdapter
|
|
43
|
+
private generateCacheKey: GenerateCacheKeyFn
|
|
44
|
+
|
|
45
|
+
// Returns a set of server URLs that have been connected to the runner
|
|
46
|
+
public get servers(): Set<string> {
|
|
47
|
+
return new Set(this.serverConfigMap.keys())
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* NOTE: This constructor is private to prevent direct instantiation.
|
|
52
|
+
* Use the static init() method to create a new Runner instance.
|
|
53
|
+
*/
|
|
54
|
+
private constructor({
|
|
55
|
+
profileSlug,
|
|
56
|
+
serviceToken,
|
|
57
|
+
onAuthenticationRequired,
|
|
58
|
+
cache,
|
|
59
|
+
generateCacheKey,
|
|
60
|
+
}: {
|
|
61
|
+
profileSlug: string
|
|
62
|
+
serviceToken: string
|
|
63
|
+
onAuthenticationRequired?: OnAuthenticationRequiredCallback
|
|
64
|
+
cache?: CacheAdapter
|
|
65
|
+
generateCacheKey?: GenerateCacheKeyFn
|
|
66
|
+
}) {
|
|
67
|
+
this.profileSlug = profileSlug
|
|
68
|
+
this.serviceToken = serviceToken
|
|
69
|
+
this.onAuthenticationRequired = onAuthenticationRequired
|
|
70
|
+
this.cache = cache
|
|
71
|
+
this.generateCacheKey = generateCacheKey ?? defaultGenerateCacheKey
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
public static async init({
|
|
75
|
+
apiKey = process.env.SMITHERY_API_KEY,
|
|
76
|
+
onAuthenticationRequired,
|
|
77
|
+
cache,
|
|
78
|
+
generateCacheKey,
|
|
79
|
+
}: RunnerInitOptions = {}): Promise<Runner> {
|
|
80
|
+
if (!apiKey) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
"API key is required. Get one from: https://smithery.ai/account/api-keys",
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const { serviceToken, profileSlug } = await exchangeApiKeyForToken(apiKey)
|
|
87
|
+
|
|
88
|
+
return new Runner({
|
|
89
|
+
profileSlug,
|
|
90
|
+
serviceToken,
|
|
91
|
+
onAuthenticationRequired,
|
|
92
|
+
cache,
|
|
93
|
+
generateCacheKey,
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private getServerUrl(server: string): string {
|
|
98
|
+
return server.startsWith("http://") || server.startsWith("https://")
|
|
99
|
+
? server
|
|
100
|
+
: `https://server.smithery.ai/${server}/mcp`
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
public async use(server: string): Promise<ConnectionConfig> {
|
|
104
|
+
// Delegate to resolveServerInput which handles auth callbacks and caching
|
|
105
|
+
return this.resolveServerInput(server)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Polls the connection status for a server.
|
|
110
|
+
* Returns status without throwing errors.
|
|
111
|
+
*
|
|
112
|
+
* @param server - Server name or URL
|
|
113
|
+
* @returns "success" if connected, "needs_auth" if auth required, "error" if failed
|
|
114
|
+
*/
|
|
115
|
+
public async poll(server: string): Promise<PollResult> {
|
|
116
|
+
// Check if already connected
|
|
117
|
+
if (this.serverConfigMap.has(server)) {
|
|
118
|
+
return "success"
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const serverUrl = this.getServerUrl(server)
|
|
122
|
+
|
|
123
|
+
// Attempt connection
|
|
124
|
+
const addResult = await addServerToProfile({
|
|
125
|
+
profileSlug: this.profileSlug,
|
|
126
|
+
input: { mcpUrl: serverUrl },
|
|
127
|
+
token: this.serviceToken,
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
if (addResult.status.state === "connected") {
|
|
131
|
+
// Cache the successful connection
|
|
132
|
+
this.serverConfigMap.set(server, addResult.configId)
|
|
133
|
+
return "success"
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (addResult.status.state === "auth_required") {
|
|
137
|
+
return "needs_auth"
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return "error"
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Resolves a ServerInput to a ConnectionConfig, connecting on-the-fly if needed.
|
|
145
|
+
*/
|
|
146
|
+
private async resolveServerInput(
|
|
147
|
+
input: ServerInput,
|
|
148
|
+
): Promise<ConnectionConfig> {
|
|
149
|
+
// Already a ConnectionConfig - return as-is
|
|
150
|
+
if (typeof input !== "string") {
|
|
151
|
+
return input
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// String server name - check if already connected
|
|
155
|
+
const server = input
|
|
156
|
+
const existingConfigId = this.serverConfigMap.get(server)
|
|
157
|
+
if (existingConfigId) {
|
|
158
|
+
return {
|
|
159
|
+
serverUrl: this.getServerUrl(server),
|
|
160
|
+
configId: existingConfigId,
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Connect on-the-fly (same logic as use())
|
|
165
|
+
const serverUrl = this.getServerUrl(server)
|
|
166
|
+
let addResult = await addServerToProfile({
|
|
167
|
+
profileSlug: this.profileSlug,
|
|
168
|
+
input: { mcpUrl: serverUrl },
|
|
169
|
+
token: this.serviceToken,
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
// Handle auth required
|
|
173
|
+
if (addResult.status.state === "auth_required") {
|
|
174
|
+
if (this.onAuthenticationRequired) {
|
|
175
|
+
// Call the callback (expected to block until auth completes)
|
|
176
|
+
await this.onAuthenticationRequired(
|
|
177
|
+
addResult.status.authorizationUrl,
|
|
178
|
+
serverUrl,
|
|
179
|
+
)
|
|
180
|
+
// Retry connection after callback returns
|
|
181
|
+
addResult = await addServerToProfile({
|
|
182
|
+
profileSlug: this.profileSlug,
|
|
183
|
+
input: { mcpUrl: serverUrl },
|
|
184
|
+
token: this.serviceToken,
|
|
185
|
+
})
|
|
186
|
+
} else {
|
|
187
|
+
// No callback - throw immediately
|
|
188
|
+
throw new MCPAuthenticationRequiredError(
|
|
189
|
+
`Authentication required. To authenticate, visit the following URL and follow the instructions:`,
|
|
190
|
+
addResult.status.authorizationUrl,
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (addResult.status.state !== "connected") {
|
|
196
|
+
throw new Error(
|
|
197
|
+
`Failed to connect to MCP server: ${JSON.stringify(addResult.status)}`,
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Cache the connection
|
|
202
|
+
this.serverConfigMap.set(server, addResult.configId)
|
|
203
|
+
return { serverUrl, configId: addResult.configId }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
public async listTools(): Promise<Tool[]> {
|
|
207
|
+
const tools = await Promise.all(
|
|
208
|
+
Array.from(this.serverConfigMap.entries()).map(async ([_, configId]) => {
|
|
209
|
+
return await listTools(this.profileSlug, configId, this.serviceToken)
|
|
210
|
+
}),
|
|
211
|
+
)
|
|
212
|
+
return tools.flat()
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Plans a tool call without executing it. Returns a serializable template
|
|
217
|
+
* where input values are parameterized with {{key}} placeholders.
|
|
218
|
+
*/
|
|
219
|
+
public async plan<TInput extends z.ZodTypeAny = typeof emptyInputSchema>(
|
|
220
|
+
prompt: string,
|
|
221
|
+
using: ServerInput[],
|
|
222
|
+
options: PlanOptions<TInput> = {},
|
|
223
|
+
): Promise<ToolCallTemplate> {
|
|
224
|
+
const { model = "anthropic/claude-haiku-4.5" } = options
|
|
225
|
+
const inputSchema = (options.inputSchema ?? emptyInputSchema) as TInput
|
|
226
|
+
|
|
227
|
+
// Resolve all server inputs to ConnectionConfigs (connects on-the-fly if needed)
|
|
228
|
+
const resolvedConfigs = await Promise.all(
|
|
229
|
+
using.map(input => this.resolveServerInput(input)),
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
// Generate normalized server identifiers for cache key
|
|
233
|
+
const serverIds = using.map(s =>
|
|
234
|
+
typeof s === "string"
|
|
235
|
+
? normalizeServerIdentifier(s)
|
|
236
|
+
: normalizeServerIdentifier(s.serverUrl),
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
// Generate cache key
|
|
240
|
+
const cacheKey = this.generateCacheKey(prompt, serverIds)
|
|
241
|
+
|
|
242
|
+
// Get input keys from schema
|
|
243
|
+
const inputKeys = getSchemaKeys(inputSchema)
|
|
244
|
+
|
|
245
|
+
// Get tools from resolved servers, tracking which server provides each tool
|
|
246
|
+
const mcpToolsWithConfigs = await Promise.all(
|
|
247
|
+
resolvedConfigs.map(async (config: ConnectionConfig) => {
|
|
248
|
+
const tools = await listTools(
|
|
249
|
+
this.profileSlug,
|
|
250
|
+
config.configId,
|
|
251
|
+
this.serviceToken,
|
|
252
|
+
)
|
|
253
|
+
return tools.map((t: Tool) => ({
|
|
254
|
+
tool: t,
|
|
255
|
+
configId: config.configId,
|
|
256
|
+
serverUrl: config.serverUrl,
|
|
257
|
+
}))
|
|
258
|
+
}),
|
|
259
|
+
).then(tools => tools.flat())
|
|
260
|
+
|
|
261
|
+
// Calculate tool schema hash for cache validation
|
|
262
|
+
const toolSchemaHash = generateToolSchemaHash(
|
|
263
|
+
mcpToolsWithConfigs.map(({ tool: mcpTool }: { tool: Tool }) => ({
|
|
264
|
+
name: mcpTool.name,
|
|
265
|
+
inputSchema: mcpTool.inputSchema,
|
|
266
|
+
})),
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
// Check cache before LLM call
|
|
270
|
+
if (this.cache) {
|
|
271
|
+
try {
|
|
272
|
+
const cached = await this.cache.get(cacheKey)
|
|
273
|
+
if (cached && cached.toolSchemaHash === toolSchemaHash) {
|
|
274
|
+
// Cache hit with valid schema - return cached template
|
|
275
|
+
return {
|
|
276
|
+
toolName: cached.toolName,
|
|
277
|
+
argsTemplate: cached.params,
|
|
278
|
+
server: cached.serverId,
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
} catch {
|
|
282
|
+
// Cache error - fall through to LLM call
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Build tool -> server lookup
|
|
287
|
+
const toolServerMap = new Map<string, string>()
|
|
288
|
+
for (const { tool: mcpTool, serverUrl } of mcpToolsWithConfigs) {
|
|
289
|
+
toolServerMap.set(mcpTool.name, serverUrl)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Create tools WITHOUT execute functions (plan mode - no execution)
|
|
293
|
+
const aiTools = Object.fromEntries(
|
|
294
|
+
mcpToolsWithConfigs.map(({ tool: mcpTool }: { tool: Tool }) => [
|
|
295
|
+
mcpTool.name,
|
|
296
|
+
tool({
|
|
297
|
+
description: mcpTool.description ?? "",
|
|
298
|
+
inputSchema: jsonSchema(mcpTool.inputSchema),
|
|
299
|
+
}),
|
|
300
|
+
]),
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
// Build system prompt instructing AI to use placeholders
|
|
304
|
+
const systemPrompt =
|
|
305
|
+
inputKeys.length > 0
|
|
306
|
+
? `You have access to the following input variables that will be provided at runtime. Use them as {{variableName}} placeholders in your tool arguments:
|
|
307
|
+
${inputKeys.map((key: string) => `- {{${key}}}`).join("\n")}
|
|
308
|
+
|
|
309
|
+
IMPORTANT: When a tool argument should use one of these variables, use the {{variableName}} syntax exactly. Do not use literal values for these variables.`
|
|
310
|
+
: undefined
|
|
311
|
+
|
|
312
|
+
// Call generateText with stopWhen to get exactly one tool call
|
|
313
|
+
const result = await generateText({
|
|
314
|
+
model,
|
|
315
|
+
tools: aiTools,
|
|
316
|
+
prompt,
|
|
317
|
+
toolChoice: "required",
|
|
318
|
+
stopWhen: stepCountIs(1),
|
|
319
|
+
...(systemPrompt ? { system: systemPrompt } : {}),
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
// Extract the tool call
|
|
323
|
+
const toolCall = result.toolCalls[0]
|
|
324
|
+
if (!toolCall) {
|
|
325
|
+
throw new Error("No tool call was generated by the model")
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const serverUrl = toolServerMap.get(toolCall.toolName)
|
|
329
|
+
if (!serverUrl) {
|
|
330
|
+
throw new Error(`No server found for tool: ${toolCall.toolName}`)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Store result in cache
|
|
334
|
+
if (this.cache) {
|
|
335
|
+
try {
|
|
336
|
+
const resolution: CachedResolution = {
|
|
337
|
+
key: cacheKey,
|
|
338
|
+
serverId: serverUrl,
|
|
339
|
+
serverQualifiedName: serverUrl,
|
|
340
|
+
toolName: toolCall.toolName,
|
|
341
|
+
params: toolCall.input as Record<string, unknown>,
|
|
342
|
+
resolvedAt: new Date().toISOString(),
|
|
343
|
+
toolSchemaHash,
|
|
344
|
+
}
|
|
345
|
+
await this.cache.set(cacheKey, resolution)
|
|
346
|
+
} catch {
|
|
347
|
+
// Cache error - continue without caching
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Return serializable template with server info
|
|
352
|
+
return {
|
|
353
|
+
toolName: toolCall.toolName,
|
|
354
|
+
argsTemplate: toolCall.input as Record<string, unknown>,
|
|
355
|
+
server: serverUrl,
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Executes a tool call template with the provided input values.
|
|
361
|
+
* Completely decoupled from plan() - can be called from a different endpoint.
|
|
362
|
+
* Uses the server URL from the template to resolve the connection.
|
|
363
|
+
*/
|
|
364
|
+
public async run<TOutput extends z.ZodTypeAny = typeof defaultSchema>(
|
|
365
|
+
template: ToolCallTemplate,
|
|
366
|
+
input: Record<string, unknown>,
|
|
367
|
+
options: RunOptions<TOutput> = {},
|
|
368
|
+
): Promise<z.infer<TOutput>> {
|
|
369
|
+
const { model = "anthropic/claude-haiku-4.5" } = options
|
|
370
|
+
const outputSchema = (options.outputSchema ?? defaultSchema) as TOutput
|
|
371
|
+
const hasCustomOutputSchema = options.outputSchema !== undefined
|
|
372
|
+
|
|
373
|
+
// Resolve server - could be serverUrl or server name, connects on-the-fly if needed
|
|
374
|
+
let configId = this.serverConfigMap.get(template.server)
|
|
375
|
+
if (!configId) {
|
|
376
|
+
// Try to connect on-the-fly
|
|
377
|
+
const resolved = await this.resolveServerInput(template.server)
|
|
378
|
+
configId = resolved.configId
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Fill template placeholders with input values
|
|
382
|
+
const filledArgs = fillTemplate(template.argsTemplate, input)
|
|
383
|
+
|
|
384
|
+
console.log(`Calling MCP tool: ${template.toolName} with args:`, filledArgs)
|
|
385
|
+
|
|
386
|
+
// Execute the tool
|
|
387
|
+
const result = await callTool(
|
|
388
|
+
this.profileSlug,
|
|
389
|
+
configId,
|
|
390
|
+
template.toolName,
|
|
391
|
+
filledArgs,
|
|
392
|
+
this.serviceToken,
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
console.log("result", result)
|
|
396
|
+
|
|
397
|
+
if (result.isError) {
|
|
398
|
+
throw new Error(`Tool execution error: ${JSON.stringify(result.content)}`)
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// If outputSchema is provided, use generateText to conform result
|
|
402
|
+
if (hasCustomOutputSchema) {
|
|
403
|
+
const { output } = await generateText({
|
|
404
|
+
model,
|
|
405
|
+
prompt: `Based on the following tool result, extract and structure the information according to the schema.\n\nTool: ${template.toolName}\nResult: ${JSON.stringify(result.content)}`,
|
|
406
|
+
output: Output.object({ schema: outputSchema }),
|
|
407
|
+
})
|
|
408
|
+
return output as z.infer<TOutput>
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Return raw result wrapped in default schema format
|
|
412
|
+
return { result: JSON.stringify(result.content) } as z.infer<TOutput>
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Acts on a prompt by determining which tool to call and executing it.
|
|
417
|
+
* Convenience method that combines plan() + run().
|
|
418
|
+
*/
|
|
419
|
+
public async act<
|
|
420
|
+
TInput extends z.ZodTypeAny = typeof emptyInputSchema,
|
|
421
|
+
TOutput extends z.ZodTypeAny = typeof defaultSchema,
|
|
422
|
+
>(
|
|
423
|
+
prompt: string,
|
|
424
|
+
using: ServerInput[],
|
|
425
|
+
options: ActOptions<TInput, TOutput> = {},
|
|
426
|
+
): Promise<z.infer<TOutput>> {
|
|
427
|
+
const template = await this.plan(prompt, using, {
|
|
428
|
+
model: options.model,
|
|
429
|
+
inputSchema: options.inputSchema,
|
|
430
|
+
})
|
|
431
|
+
return this.run(
|
|
432
|
+
template,
|
|
433
|
+
{},
|
|
434
|
+
{
|
|
435
|
+
model: options.model,
|
|
436
|
+
outputSchema: options.outputSchema,
|
|
437
|
+
},
|
|
438
|
+
)
|
|
439
|
+
}
|
|
440
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { LanguageModel } from "ai"
|
|
2
|
+
import { z } from "zod"
|
|
3
|
+
import type { CacheAdapter, GenerateCacheKeyFn } from "./cache/types.js"
|
|
4
|
+
|
|
5
|
+
export type ConnectionConfig = {
|
|
6
|
+
serverUrl: string
|
|
7
|
+
configId: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Union type for act/plan's `using` parameter - accepts either pre-connected config or server name string
|
|
11
|
+
export type ServerInput = ConnectionConfig | string
|
|
12
|
+
|
|
13
|
+
// Poll result type
|
|
14
|
+
export type PollResult = "success" | "needs_auth" | "error"
|
|
15
|
+
|
|
16
|
+
// Auth callback type - called when authentication is required during connection
|
|
17
|
+
export type OnAuthenticationRequiredCallback = (
|
|
18
|
+
authorizationUrl: string,
|
|
19
|
+
serverUrl: string,
|
|
20
|
+
) => Promise<void>
|
|
21
|
+
|
|
22
|
+
// Init options type
|
|
23
|
+
export type RunnerInitOptions = {
|
|
24
|
+
apiKey?: string
|
|
25
|
+
onAuthenticationRequired?: OnAuthenticationRequiredCallback
|
|
26
|
+
/** Optional cache adapter for storing plan() resolutions */
|
|
27
|
+
cache?: CacheAdapter
|
|
28
|
+
/** Optional custom cache key generation function */
|
|
29
|
+
generateCacheKey?: GenerateCacheKeyFn
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const defaultSchema = z.object({ result: z.string() })
|
|
33
|
+
export const emptyInputSchema = z.object({})
|
|
34
|
+
|
|
35
|
+
// Serializable template returned by plan()
|
|
36
|
+
export type ToolCallTemplate = {
|
|
37
|
+
toolName: string
|
|
38
|
+
argsTemplate: Record<string, unknown>
|
|
39
|
+
server: string // server URL to execute the tool on
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Options for plan()
|
|
43
|
+
export type PlanOptions<TInput extends z.ZodTypeAny = typeof emptyInputSchema> =
|
|
44
|
+
{
|
|
45
|
+
model?: LanguageModel
|
|
46
|
+
inputSchema?: TInput
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Options for run()
|
|
50
|
+
export type RunOptions<TOutput extends z.ZodTypeAny = typeof defaultSchema> = {
|
|
51
|
+
model?: LanguageModel
|
|
52
|
+
outputSchema?: TOutput
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Options for act() - combines plan + run options
|
|
56
|
+
export type ActOptions<
|
|
57
|
+
TInput extends z.ZodTypeAny = typeof emptyInputSchema,
|
|
58
|
+
TOutput extends z.ZodTypeAny = typeof defaultSchema,
|
|
59
|
+
> = PlanOptions<TInput> & RunOptions<TOutput>
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { z } from "zod"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Fills {{key}} placeholders in template with input values.
|
|
5
|
+
*/
|
|
6
|
+
export function fillTemplate(
|
|
7
|
+
template: Record<string, unknown>,
|
|
8
|
+
input: Record<string, unknown>,
|
|
9
|
+
): Record<string, unknown> {
|
|
10
|
+
function fillValue(value: unknown): unknown {
|
|
11
|
+
if (typeof value === "string") {
|
|
12
|
+
return value.replace(/\{\{(\w+)\}\}/g, (match, key) => {
|
|
13
|
+
if (key in input) {
|
|
14
|
+
return String(input[key])
|
|
15
|
+
}
|
|
16
|
+
return match
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
if (Array.isArray(value)) {
|
|
20
|
+
return value.map(fillValue)
|
|
21
|
+
}
|
|
22
|
+
if (value !== null && typeof value === "object") {
|
|
23
|
+
return fillTemplate(value as Record<string, unknown>, input)
|
|
24
|
+
}
|
|
25
|
+
return value
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const result: Record<string, unknown> = {}
|
|
29
|
+
for (const [key, value] of Object.entries(template)) {
|
|
30
|
+
result[key] = fillValue(value)
|
|
31
|
+
}
|
|
32
|
+
return result
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Extracts the keys from a Zod object schema.
|
|
37
|
+
*/
|
|
38
|
+
export function getSchemaKeys(schema: z.ZodTypeAny): string[] {
|
|
39
|
+
if (schema instanceof z.ZodObject) {
|
|
40
|
+
return Object.keys(schema.shape)
|
|
41
|
+
}
|
|
42
|
+
return []
|
|
43
|
+
}
|