apps 1.0.0-beta.1 → 1.0.0-beta.3
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/README.md +3 -2
- package/dist/authoring/index.d.ts +222 -34
- package/dist/authoring/index.js +11 -1
- package/dist/{chunk-NFLNA2SC.js → chunk-4OZFMCTT.js} +7 -1
- package/dist/{chunk-LWKQDDLZ.js → chunk-A3H32P2J.js} +46 -1
- package/dist/chunk-F7GZYEAC.js +41 -0
- package/dist/chunk-MUFMJRXR.js +308 -0
- package/dist/chunk-S5KFP4JQ.js +20 -0
- package/dist/chunk-Y6LLTN7W.js +99 -0
- package/dist/chunk-YUZ4TXOY.js +27 -0
- package/dist/http/index.d.ts +9 -0
- package/dist/http/index.js +6 -0
- package/dist/index.d.ts +3 -8
- package/dist/index.js +23 -1
- package/dist/manifest/contract.d.ts +8 -0
- package/dist/manifest/index.d.ts +8 -6
- package/dist/manifest/index.js +3 -3
- package/dist/mcp/client.d.ts +9 -0
- package/dist/mcp/index.d.ts +11 -0
- package/dist/mcp/index.js +7 -0
- package/dist/openapi/index.d.ts +14 -0
- package/dist/openapi/index.js +7 -0
- package/dist/openapi/mapper.d.ts +7 -0
- package/dist/runner/index.js +36 -51
- package/dist/runner/protocol.d.ts +20 -15
- package/dist/runner/protocol.js +5 -3
- package/dist/runtime/index.d.ts +83 -0
- package/dist/runtime/index.js +12 -0
- package/dist/workerd/index.d.ts +7 -2
- package/dist/workerd/index.js +7 -3
- package/package.json +18 -7
- package/src/authoring/index.ts +362 -44
- package/src/http/index.ts +26 -0
- package/src/index.ts +3 -8
- package/src/manifest/contract.ts +9 -0
- package/src/manifest/index.ts +89 -157
- package/src/mcp/client.ts +59 -0
- package/src/mcp/index.ts +42 -0
- package/src/openapi/index.ts +70 -0
- package/src/openapi/mapper.ts +25 -0
- package/src/runner/index.ts +50 -87
- package/src/runner/protocol.ts +34 -21
- package/src/runtime/index.ts +550 -0
- package/src/workerd/index.ts +17 -2
- package/dist/chunk-J5AVXTHA.js +0 -154
- package/dist/chunk-RZA7AXBK.js +0 -107
- package/dist/manifest/collect.d.ts +0 -64
- package/src/manifest/collect.ts +0 -198
package/src/manifest/index.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Manifest: execute an authored app folder
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Manifest: execute an authored app folder and project its registry.
|
|
3
|
+
* This module owns exactly what differs on a host with a filesystem —
|
|
4
|
+
* finding and importing authored modules. Everything after the modules
|
|
5
|
+
* load (validation, canonicalization, digesting) is the shared
|
|
6
|
+
* execution core in ../runtime; sandboxed guests run that same core.
|
|
5
7
|
*/
|
|
6
8
|
|
|
7
9
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
8
|
-
import {
|
|
10
|
+
import { Effect, FileSystem, MutableRef, Semaphore } from "effect";
|
|
9
11
|
import {
|
|
10
12
|
__enterFile,
|
|
11
13
|
__leaveFile,
|
|
@@ -13,118 +15,51 @@ import {
|
|
|
13
15
|
setActiveRegistry,
|
|
14
16
|
type Registry,
|
|
15
17
|
} from "../authoring/index";
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
JsonSchemaObject,
|
|
19
|
-
PublishError,
|
|
20
|
-
type AppManifestResult,
|
|
21
|
-
type JsonSchemaObject as JsonSchemaObjectType,
|
|
22
|
-
} from "./contract";
|
|
18
|
+
import { manifestOf } from "../runtime/index";
|
|
19
|
+
import { PublishError, type AppManifestResult } from "./contract";
|
|
23
20
|
|
|
24
21
|
export * from "./contract";
|
|
25
22
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
readonly jsonSchema?: {
|
|
29
|
-
readonly input: (options: { readonly target: string }) => unknown;
|
|
30
|
-
};
|
|
31
|
-
};
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const hasStandardJsonSchema = (input: object): input is StandardJsonSchemaCapable =>
|
|
35
|
-
"~standard" in input &&
|
|
36
|
-
typeof (input as StandardJsonSchemaCapable)["~standard"].jsonSchema?.input === "function";
|
|
37
|
-
|
|
38
|
-
const compileInputSchema = (
|
|
39
|
-
input: object,
|
|
40
|
-
where: string,
|
|
41
|
-
): InputSchemaCompilation => {
|
|
42
|
-
if ("~standard" in input) {
|
|
43
|
-
if (!hasStandardJsonSchema(input))
|
|
44
|
-
return {
|
|
45
|
-
problem: `${where}: input is a Standard Schema without the jsonSchema extension — use a library that implements it (zod 4+, effect Schema) or pass raw JSON schema`,
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
try {
|
|
50
|
-
const compiled = strictify(
|
|
51
|
-
hasStandardJsonSchema(input)
|
|
52
|
-
? input["~standard"].jsonSchema!.input({ target: "draft-2020-12" })
|
|
53
|
-
: structuredClone(input),
|
|
54
|
-
);
|
|
55
|
-
return { value: Schema.decodeUnknownSync(JsonSchemaObject)(compiled) as JsonSchemaObjectType };
|
|
56
|
-
} catch (error) {
|
|
57
|
-
return { problem: schemaPublishProblem(where, error) };
|
|
58
|
-
}
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
const schemaPublishProblem = (where: string, error: unknown): string =>
|
|
62
|
-
`${where}: input schema could not compile to JSON schema (${
|
|
63
|
-
error instanceof Error ? error.message : String(error)
|
|
64
|
-
})`;
|
|
65
|
-
|
|
66
|
-
const strictify = (node: unknown): unknown => {
|
|
67
|
-
if (typeof node !== "object" || node === null) return node;
|
|
68
|
-
if (Array.isArray(node)) return node.map(strictify);
|
|
69
|
-
const object = { ...(node as Record<string, unknown>) };
|
|
70
|
-
if (object.type === "object" && object.additionalProperties === undefined)
|
|
71
|
-
object.additionalProperties = false;
|
|
72
|
-
for (const key of ["items", "anyOf", "oneOf", "allOf"])
|
|
73
|
-
if (key in object) object[key] = strictify(object[key]);
|
|
74
|
-
for (const key of ["properties", "$defs"])
|
|
75
|
-
if (object[key] !== undefined && typeof object[key] === "object" && object[key] !== null)
|
|
76
|
-
object[key] = Object.fromEntries(
|
|
77
|
-
Object.entries(object[key] as Record<string, unknown>).map(([name, value]) => [
|
|
78
|
-
name,
|
|
79
|
-
strictify(value),
|
|
80
|
-
]),
|
|
81
|
-
);
|
|
82
|
-
return object;
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
const digestOf = (content: string): Effect.Effect<string, never, Crypto.Crypto> =>
|
|
86
|
-
Effect.gen(function* () {
|
|
87
|
-
const crypto = yield* Crypto.Crypto;
|
|
88
|
-
const bytes = yield* Effect.orDie(
|
|
89
|
-
crypto.digest("SHA-256", new TextEncoder().encode(content)),
|
|
90
|
-
);
|
|
91
|
-
return Array.from(bytes.slice(0, 8), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
92
|
-
});
|
|
23
|
+
const moduleProblem = (where: string, error: unknown): string =>
|
|
24
|
+
`${where}: failed to execute (${error instanceof Error ? error.message : String(error)})`;
|
|
93
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Module execution is process-global twice over: the authoring
|
|
28
|
+
* registry is a singleton the imported modules write into, and the
|
|
29
|
+
* ESM module cache is shared. One permit serializes loads; the nonce
|
|
30
|
+
* and first-import memo make re-imports observable across runs.
|
|
31
|
+
* Module-scoped by necessity — this state IS process state.
|
|
32
|
+
*/
|
|
94
33
|
const loadPermit = Semaphore.makeUnsafe(1);
|
|
95
|
-
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
const moduleFailure = (where: string, error: unknown): PublishError =>
|
|
99
|
-
new PublishError({
|
|
100
|
-
problems: [
|
|
101
|
-
`${where}: failed to execute (${error instanceof Error ? error.message : String(error)})`,
|
|
102
|
-
],
|
|
103
|
-
});
|
|
34
|
+
const runNonce = MutableRef.make(0);
|
|
35
|
+
const importedAppUrls = MutableRef.make<ReadonlySet<string>>(new Set());
|
|
104
36
|
|
|
105
37
|
const canonicalModuleUrl = (
|
|
106
38
|
path: string,
|
|
107
39
|
where: string,
|
|
108
40
|
): Effect.Effect<URL, PublishError, FileSystem.FileSystem> =>
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
return pathToFileURL(realPath);
|
|
115
|
-
});
|
|
41
|
+
FileSystem.FileSystem.pipe(
|
|
42
|
+
Effect.flatMap((fs) => fs.realPath(path)),
|
|
43
|
+
Effect.map(pathToFileURL),
|
|
44
|
+
Effect.mapError((error) => new PublishError({ problems: [moduleProblem(where, error)] })),
|
|
45
|
+
);
|
|
116
46
|
|
|
117
47
|
const importModule = (specifier: string, where: string): Effect.Effect<void, PublishError> =>
|
|
118
48
|
Effect.tryPromise({
|
|
119
|
-
try: () => import(specifier)
|
|
120
|
-
catch: (error) =>
|
|
121
|
-
});
|
|
49
|
+
try: () => import(specifier),
|
|
50
|
+
catch: (error) => new PublishError({ problems: [moduleProblem(where, error)] }),
|
|
51
|
+
}).pipe(Effect.asVoid);
|
|
122
52
|
|
|
123
|
-
|
|
53
|
+
/** Import one authored module, converting failure into registry problems. */
|
|
54
|
+
const executeModule = (
|
|
124
55
|
registry: Registry,
|
|
125
|
-
|
|
56
|
+
where: string,
|
|
57
|
+
load: Effect.Effect<void, PublishError, FileSystem.FileSystem>,
|
|
126
58
|
): Effect.Effect<void, never, FileSystem.FileSystem> =>
|
|
127
|
-
|
|
59
|
+
Effect.suspend(() => {
|
|
60
|
+
__enterFile(where);
|
|
61
|
+
return load;
|
|
62
|
+
}).pipe(
|
|
128
63
|
Effect.catchTag("PublishError", (error) =>
|
|
129
64
|
Effect.sync(() => {
|
|
130
65
|
registry.problems.push(...error.problems.map((message) => ({ message })));
|
|
@@ -132,58 +67,70 @@ const recordModuleProblems = (
|
|
|
132
67
|
),
|
|
133
68
|
);
|
|
134
69
|
|
|
70
|
+
/**
|
|
71
|
+
* The app entry loads through its plain canonical URL the first time
|
|
72
|
+
* so relative imports resolve normally, and through a nonce query on
|
|
73
|
+
* every later run so edits are observed instead of served stale from
|
|
74
|
+
* the module cache.
|
|
75
|
+
*/
|
|
76
|
+
const appEntrySpecifier = (appUrl: URL, nonce: number): string => {
|
|
77
|
+
const seen = MutableRef.get(importedAppUrls);
|
|
78
|
+
if (!seen.has(appUrl.href)) {
|
|
79
|
+
MutableRef.set(importedAppUrls, new Set([...seen, appUrl.href]));
|
|
80
|
+
return appUrl.href;
|
|
81
|
+
}
|
|
82
|
+
return `${fileURLToPath(appUrl)}?m=${nonce}`;
|
|
83
|
+
};
|
|
84
|
+
|
|
135
85
|
const executeModules = (
|
|
136
86
|
appDir: string,
|
|
137
87
|
files: readonly string[],
|
|
138
88
|
): Effect.Effect<Registry, never, FileSystem.FileSystem> =>
|
|
139
89
|
loadPermit.withPermit(
|
|
140
|
-
Effect.
|
|
141
|
-
Effect.
|
|
90
|
+
Effect.acquireUseRelease(
|
|
91
|
+
Effect.sync(() => {
|
|
142
92
|
const registry = newRegistry();
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
yield*
|
|
93
|
+
return { registry, previous: setActiveRegistry(registry) };
|
|
94
|
+
}),
|
|
95
|
+
({ registry }) =>
|
|
96
|
+
Effect.gen(function* () {
|
|
97
|
+
const nonce = MutableRef.incrementAndGet(runNonce);
|
|
98
|
+
yield* executeModule(
|
|
149
99
|
registry,
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
? `${fileURLToPath(appUrl)}?m=${nonce}`
|
|
155
|
-
: baseUrl;
|
|
156
|
-
yield* importModule(specifier, "app.ts");
|
|
157
|
-
yield* Effect.sync(() => baseAppImports.add(baseUrl));
|
|
158
|
-
}),
|
|
100
|
+
"app.ts",
|
|
101
|
+
canonicalModuleUrl(`${appDir}/app.ts`, "app.ts").pipe(
|
|
102
|
+
Effect.flatMap((appUrl) => importModule(appEntrySpecifier(appUrl, nonce), "app.ts")),
|
|
103
|
+
),
|
|
159
104
|
);
|
|
160
|
-
|
|
161
105
|
for (const file of files) {
|
|
162
106
|
const where = `tools/${file}`;
|
|
163
|
-
|
|
164
|
-
yield* recordModuleProblems(
|
|
107
|
+
yield* executeModule(
|
|
165
108
|
registry,
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
109
|
+
where,
|
|
110
|
+
canonicalModuleUrl(`${appDir}/tools/${file}`, where).pipe(
|
|
111
|
+
Effect.flatMap((url) => importModule(`${fileURLToPath(url)}?m=${nonce}`, where)),
|
|
112
|
+
),
|
|
170
113
|
);
|
|
171
114
|
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
}),
|
|
180
|
-
),
|
|
181
|
-
);
|
|
182
|
-
return registry;
|
|
183
|
-
}),
|
|
115
|
+
return registry;
|
|
116
|
+
}),
|
|
117
|
+
({ previous }) =>
|
|
118
|
+
Effect.sync(() => {
|
|
119
|
+
__leaveFile();
|
|
120
|
+
setActiveRegistry(previous);
|
|
121
|
+
}),
|
|
184
122
|
),
|
|
185
123
|
);
|
|
186
124
|
|
|
125
|
+
const toolFiles = (
|
|
126
|
+
appDir: string,
|
|
127
|
+
): Effect.Effect<readonly string[], PublishError, FileSystem.FileSystem> =>
|
|
128
|
+
FileSystem.FileSystem.pipe(
|
|
129
|
+
Effect.flatMap((fs) => fs.readDirectory(`${appDir}/tools`)),
|
|
130
|
+
Effect.mapError((error) => new PublishError({ problems: [`tools/: ${error.message}`] })),
|
|
131
|
+
Effect.map((entries) => entries.filter((file) => file.endsWith(".ts")).sort()),
|
|
132
|
+
);
|
|
133
|
+
|
|
187
134
|
/** A registry and its validated public projection. @internal */
|
|
188
135
|
export interface LoadedApp {
|
|
189
136
|
readonly registry: Registry;
|
|
@@ -193,32 +140,17 @@ export interface LoadedApp {
|
|
|
193
140
|
/** Loads an app once and returns both its live registry and manifest. @internal */
|
|
194
141
|
export const loadApp = (
|
|
195
142
|
appDir: string,
|
|
196
|
-
): Effect.Effect<LoadedApp, PublishError,
|
|
143
|
+
): Effect.Effect<LoadedApp, PublishError, FileSystem.FileSystem> =>
|
|
197
144
|
Effect.gen(function* () {
|
|
198
|
-
const
|
|
199
|
-
const files = (yield* fs.readDirectory(`${appDir}/tools`).pipe(
|
|
200
|
-
Effect.mapError((error) => new PublishError({ problems: [`tools/: ${error.message}`] })),
|
|
201
|
-
))
|
|
202
|
-
.filter((file) => file.endsWith(".ts"))
|
|
203
|
-
.sort();
|
|
145
|
+
const files = yield* toolFiles(appDir);
|
|
204
146
|
const registry = yield* executeModules(appDir, files);
|
|
205
|
-
const
|
|
206
|
-
if (
|
|
207
|
-
|
|
208
|
-
if (!("manifest" in collected))
|
|
209
|
-
return yield* Effect.die("validated registry projection unexpectedly missing");
|
|
210
|
-
|
|
211
|
-
return {
|
|
212
|
-
registry,
|
|
213
|
-
result: {
|
|
214
|
-
digest: `snap:${yield* digestOf(collected.canonical)}`,
|
|
215
|
-
...collected.manifest,
|
|
216
|
-
},
|
|
217
|
-
};
|
|
147
|
+
const outcome = yield* Effect.promise(() => manifestOf(registry));
|
|
148
|
+
if (!outcome.ok) return yield* new PublishError({ problems: outcome.problems });
|
|
149
|
+
return { registry, result: outcome.result };
|
|
218
150
|
});
|
|
219
151
|
|
|
220
152
|
/** Executes an app and returns its deterministic publish manifest. */
|
|
221
153
|
export const manifest = (
|
|
222
154
|
appDir: string,
|
|
223
|
-
): Effect.Effect<AppManifestResult, PublishError,
|
|
155
|
+
): Effect.Effect<AppManifestResult, PublishError, FileSystem.FileSystem> =>
|
|
224
156
|
Effect.map(loadApp(appDir), ({ result }) => result);
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** Stub-contract MCP client used by the fromMCP dynamic pair. */
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
AuthComposeFn,
|
|
5
|
+
DiscoveredToolSpec,
|
|
6
|
+
OperationSelection,
|
|
7
|
+
} from "../authoring/index";
|
|
8
|
+
|
|
9
|
+
/** Normalize authored placement, or apply MCP's bearer default. */
|
|
10
|
+
export const composePlacement = (
|
|
11
|
+
auth: AuthComposeFn<never> | undefined,
|
|
12
|
+
ctx: unknown,
|
|
13
|
+
): { headers: Headers; searchParams: URLSearchParams } => {
|
|
14
|
+
const headers = new Headers();
|
|
15
|
+
const searchParams = new URLSearchParams();
|
|
16
|
+
const roles = ctx as Record<string, { token?: string }> | undefined;
|
|
17
|
+
|
|
18
|
+
if (auth === undefined) {
|
|
19
|
+
const role = roles === undefined ? undefined : Object.values(roles)[0];
|
|
20
|
+
if (role?.token !== undefined) headers.set("authorization", `Bearer ${role.token}`);
|
|
21
|
+
return { headers, searchParams };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const placement = (auth as (roles: unknown) => unknown)(roles ?? {});
|
|
25
|
+
if (placement !== undefined && placement !== null && typeof placement === "object") {
|
|
26
|
+
const normalized = placement as {
|
|
27
|
+
readonly headers?: HeadersInit;
|
|
28
|
+
readonly searchParams?: URLSearchParams | Readonly<Record<string, string>>;
|
|
29
|
+
};
|
|
30
|
+
if (normalized.headers !== undefined)
|
|
31
|
+
new Headers(normalized.headers).forEach((value, key) => headers.set(key, value));
|
|
32
|
+
if (normalized.searchParams !== undefined)
|
|
33
|
+
new URLSearchParams(
|
|
34
|
+
normalized.searchParams instanceof URLSearchParams
|
|
35
|
+
? normalized.searchParams
|
|
36
|
+
: { ...normalized.searchParams },
|
|
37
|
+
).forEach((value, key) => searchParams.set(key, value));
|
|
38
|
+
}
|
|
39
|
+
return { headers, searchParams };
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const mcpDiscover = async (
|
|
43
|
+
_endpoint: string,
|
|
44
|
+
_ctx: unknown,
|
|
45
|
+
_auth: AuthComposeFn<never> | undefined,
|
|
46
|
+
_operations: OperationSelection | undefined,
|
|
47
|
+
): Promise<readonly DiscoveredToolSpec[]> => {
|
|
48
|
+
throw new Error("mcp client not built yet");
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const mcpCall = async (
|
|
52
|
+
_endpoint: string,
|
|
53
|
+
_tool: string,
|
|
54
|
+
_input: unknown,
|
|
55
|
+
_ctx: unknown,
|
|
56
|
+
_auth: AuthComposeFn<never> | undefined,
|
|
57
|
+
): Promise<unknown> => {
|
|
58
|
+
throw new Error("mcp client not built yet");
|
|
59
|
+
};
|
package/src/mcp/index.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** fromMCP sugar over the authored dynamic-tools primitive. */
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
defineDynamicTools,
|
|
5
|
+
type AuthComposeFn,
|
|
6
|
+
type DynamicToolSet,
|
|
7
|
+
type OperationSelection,
|
|
8
|
+
type Requirement,
|
|
9
|
+
} from "../authoring/index";
|
|
10
|
+
import { mcpCall, mcpDiscover } from "./client";
|
|
11
|
+
|
|
12
|
+
export type FromMCPOptions<Reqs extends Readonly<Record<string, Requirement>>> = {
|
|
13
|
+
readonly requirements: Reqs;
|
|
14
|
+
readonly auth?: AuthComposeFn<NoInfer<Reqs>>;
|
|
15
|
+
readonly operations?: OperationSelection;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function fromMCP<const Reqs extends Readonly<Record<string, Requirement>>>(
|
|
19
|
+
endpoint: string,
|
|
20
|
+
options: FromMCPOptions<Reqs>,
|
|
21
|
+
): DynamicToolSet;
|
|
22
|
+
export function fromMCP(
|
|
23
|
+
endpoint: string,
|
|
24
|
+
options?: { readonly operations?: OperationSelection },
|
|
25
|
+
): DynamicToolSet;
|
|
26
|
+
export function fromMCP(
|
|
27
|
+
endpoint: string,
|
|
28
|
+
options?: {
|
|
29
|
+
readonly requirements?: Readonly<Record<string, Requirement>>;
|
|
30
|
+
readonly auth?: AuthComposeFn<never>;
|
|
31
|
+
readonly operations?: OperationSelection;
|
|
32
|
+
},
|
|
33
|
+
): DynamicToolSet {
|
|
34
|
+
const auth = options?.auth;
|
|
35
|
+
const operations = options?.operations;
|
|
36
|
+
return defineDynamicTools({
|
|
37
|
+
description: `Tools served by the MCP server at ${endpoint}.`,
|
|
38
|
+
...(options?.requirements === undefined ? {} : { requirements: options.requirements }),
|
|
39
|
+
discover: (ctx) => mcpDiscover(endpoint, ctx, auth, operations),
|
|
40
|
+
call: (tool, input, ctx) => mcpCall(endpoint, tool.name, input, ctx, auth),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/** fromOpenAPI sugar over the authored dynamic-tools primitive. */
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
defineDynamicTools,
|
|
5
|
+
recordAuthoringProblem,
|
|
6
|
+
requirementSpecOf,
|
|
7
|
+
type AuthComposeFn,
|
|
8
|
+
type DynamicToolSet,
|
|
9
|
+
type OperationSelection,
|
|
10
|
+
type Requirement,
|
|
11
|
+
type RequiresAuthCompose,
|
|
12
|
+
} from "../authoring/index";
|
|
13
|
+
import { openapiCall, openapiDiscover } from "./mapper";
|
|
14
|
+
|
|
15
|
+
export type FromOpenAPIOptions<Reqs extends Readonly<Record<string, Requirement>>> = {
|
|
16
|
+
readonly requirements: Reqs;
|
|
17
|
+
readonly auth?: AuthComposeFn<NoInfer<Reqs>>;
|
|
18
|
+
readonly operations?: OperationSelection;
|
|
19
|
+
} & (RequiresAuthCompose<Reqs> extends true
|
|
20
|
+
? { readonly auth: AuthComposeFn<NoInfer<Reqs>> }
|
|
21
|
+
: unknown);
|
|
22
|
+
|
|
23
|
+
const enforceSecretsPlacement = (
|
|
24
|
+
requirements: Readonly<Record<string, Requirement>> | undefined,
|
|
25
|
+
auth: AuthComposeFn<never> | undefined,
|
|
26
|
+
): void => {
|
|
27
|
+
if (auth !== undefined) return;
|
|
28
|
+
for (const value of Object.values(requirements ?? {})) {
|
|
29
|
+
const spec = requirementSpecOf(value);
|
|
30
|
+
const methods = spec?.grant === undefined
|
|
31
|
+
? Object.values(spec?.service.auth ?? {})
|
|
32
|
+
: [spec.service.auth?.[spec.grant]].filter((method) => method !== undefined);
|
|
33
|
+
if (methods.some((method) => method.kind === "secrets"))
|
|
34
|
+
recordAuthoringProblem(
|
|
35
|
+
"fromOpenAPI: the service offers a secrets() auth method, so 'auth' must state how the credential rides",
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type OpenAPIDocumentInput = string | Readonly<Record<string, unknown>>;
|
|
41
|
+
|
|
42
|
+
export function fromOpenAPI<const Reqs extends Readonly<Record<string, Requirement>>>(
|
|
43
|
+
spec: OpenAPIDocumentInput,
|
|
44
|
+
options: FromOpenAPIOptions<Reqs>,
|
|
45
|
+
): DynamicToolSet;
|
|
46
|
+
export function fromOpenAPI(
|
|
47
|
+
spec: OpenAPIDocumentInput,
|
|
48
|
+
options?: { readonly operations?: OperationSelection },
|
|
49
|
+
): DynamicToolSet;
|
|
50
|
+
export function fromOpenAPI(
|
|
51
|
+
spec: OpenAPIDocumentInput,
|
|
52
|
+
options?: {
|
|
53
|
+
readonly requirements?: Readonly<Record<string, Requirement>>;
|
|
54
|
+
readonly auth?: AuthComposeFn<never>;
|
|
55
|
+
readonly operations?: OperationSelection;
|
|
56
|
+
},
|
|
57
|
+
): DynamicToolSet {
|
|
58
|
+
enforceSecretsPlacement(options?.requirements, options?.auth);
|
|
59
|
+
const auth = options?.auth;
|
|
60
|
+
const operations = options?.operations;
|
|
61
|
+
return defineDynamicTools({
|
|
62
|
+
description:
|
|
63
|
+
typeof spec === "string" && spec.startsWith("https://")
|
|
64
|
+
? `Operations from the OpenAPI document at ${spec}.`
|
|
65
|
+
: "Operations from an OpenAPI document shipped with the app.",
|
|
66
|
+
...(options?.requirements === undefined ? {} : { requirements: options.requirements }),
|
|
67
|
+
discover: (ctx) => openapiDiscover(spec, ctx, auth, operations),
|
|
68
|
+
call: (tool, input, ctx) => openapiCall(tool, input, ctx, auth),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Stub-contract OpenAPI discovery and call mapper. */
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
AuthComposeFn,
|
|
5
|
+
DiscoveredToolSpec,
|
|
6
|
+
OperationSelection,
|
|
7
|
+
} from "../authoring/index";
|
|
8
|
+
|
|
9
|
+
export const openapiDiscover = async (
|
|
10
|
+
_spec: string | Readonly<Record<string, unknown>>,
|
|
11
|
+
_ctx: unknown,
|
|
12
|
+
_auth: AuthComposeFn<never> | undefined,
|
|
13
|
+
_operations: OperationSelection | undefined,
|
|
14
|
+
): Promise<readonly DiscoveredToolSpec[]> => {
|
|
15
|
+
throw new Error("openapi mapper not built yet");
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const openapiCall = async (
|
|
19
|
+
_tool: { readonly name: string; readonly detail: unknown },
|
|
20
|
+
_input: unknown,
|
|
21
|
+
_ctx: unknown,
|
|
22
|
+
_auth: AuthComposeFn<never> | undefined,
|
|
23
|
+
): Promise<unknown> => {
|
|
24
|
+
throw new Error("openapi mapper not built yet");
|
|
25
|
+
};
|
package/src/runner/index.ts
CHANGED
|
@@ -1,73 +1,26 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { ToolSpec } from "../authoring/index";
|
|
1
|
+
import { Effect, FileSystem } from "effect";
|
|
3
2
|
import {
|
|
4
3
|
loadApp,
|
|
5
4
|
type LoadedApp,
|
|
6
5
|
type PublishError,
|
|
7
6
|
} from "../manifest/index";
|
|
8
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
callTool,
|
|
9
|
+
discoverTool,
|
|
10
|
+
type CallOutcome,
|
|
11
|
+
type DiscoverOutcome,
|
|
12
|
+
} from "../runtime/index";
|
|
13
|
+
import { CallError, DiscoverError, type AppRunner } from "./protocol";
|
|
9
14
|
|
|
10
15
|
export * from "./protocol";
|
|
11
16
|
|
|
12
|
-
interface StandardSchemaCapable {
|
|
13
|
-
readonly "~standard": {
|
|
14
|
-
readonly validate: (
|
|
15
|
-
value: unknown,
|
|
16
|
-
) =>
|
|
17
|
-
| { readonly value: unknown; readonly issues?: undefined }
|
|
18
|
-
| { readonly issues: ReadonlyArray<{ readonly message: string }> }
|
|
19
|
-
| Promise<
|
|
20
|
-
| { readonly value: unknown; readonly issues?: undefined }
|
|
21
|
-
| { readonly issues: ReadonlyArray<{ readonly message: string }> }
|
|
22
|
-
>;
|
|
23
|
-
};
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const hasStandardValidate = (input: object): input is StandardSchemaCapable =>
|
|
27
|
-
"~standard" in input &&
|
|
28
|
-
typeof (input as StandardSchemaCapable)["~standard"].validate === "function";
|
|
29
|
-
|
|
30
|
-
const parseInput = (name: string, spec: ToolSpec, input: unknown): Effect.Effect<unknown, CallError> => {
|
|
31
|
-
if (!hasStandardValidate(spec.input))
|
|
32
|
-
return Effect.fail(
|
|
33
|
-
new CallError({ target: name, reason: "input schema does not implement Standard Schema validate" }),
|
|
34
|
-
);
|
|
35
|
-
const standard = spec.input["~standard"];
|
|
36
|
-
|
|
37
|
-
return Effect.tryPromise({
|
|
38
|
-
try: () => Promise.resolve(standard.validate(input)),
|
|
39
|
-
catch: (error) =>
|
|
40
|
-
new CallError({
|
|
41
|
-
target: name,
|
|
42
|
-
reason: `input validation threw (${error instanceof Error ? error.message : String(error)})`,
|
|
43
|
-
}),
|
|
44
|
-
}).pipe(
|
|
45
|
-
Effect.flatMap((result) =>
|
|
46
|
-
result.issues === undefined
|
|
47
|
-
? Effect.succeed(result.value)
|
|
48
|
-
: Effect.fail(
|
|
49
|
-
new CallError({
|
|
50
|
-
target: name,
|
|
51
|
-
reason: `invalid input (${result.issues.map((issue) => issue.message).join("; ")})`,
|
|
52
|
-
}),
|
|
53
|
-
),
|
|
54
|
-
),
|
|
55
|
-
);
|
|
56
|
-
};
|
|
57
|
-
|
|
58
17
|
/** Creates a permissive in-process runner for local development and tests. */
|
|
59
18
|
export const localRunner = (appDir: string): AppRunner => {
|
|
60
|
-
let cachedLoad:
|
|
61
|
-
LoadedApp,
|
|
62
|
-
|
|
63
|
-
Crypto.Crypto | FileSystem.FileSystem
|
|
64
|
-
> | undefined;
|
|
19
|
+
let cachedLoad:
|
|
20
|
+
| Effect.Effect<LoadedApp, PublishError, FileSystem.FileSystem>
|
|
21
|
+
| undefined;
|
|
65
22
|
|
|
66
|
-
const loaded = (): Effect.Effect<
|
|
67
|
-
LoadedApp,
|
|
68
|
-
PublishError,
|
|
69
|
-
Crypto.Crypto | FileSystem.FileSystem
|
|
70
|
-
> =>
|
|
23
|
+
const loaded = (): Effect.Effect<LoadedApp, PublishError, FileSystem.FileSystem> =>
|
|
71
24
|
Effect.suspend(() => {
|
|
72
25
|
if (cachedLoad !== undefined) return cachedLoad;
|
|
73
26
|
return Effect.flatMap(Effect.cached(loadApp(appDir)), (effect) =>
|
|
@@ -78,40 +31,50 @@ export const localRunner = (appDir: string): AppRunner => {
|
|
|
78
31
|
);
|
|
79
32
|
});
|
|
80
33
|
|
|
81
|
-
const
|
|
82
|
-
target: string,
|
|
83
|
-
): Effect.Effect<LoadedApp, CallError, Crypto.Crypto | FileSystem.FileSystem> =>
|
|
34
|
+
const registryFor = (target: string) =>
|
|
84
35
|
loaded().pipe(
|
|
36
|
+
Effect.map(({ registry }) => registry),
|
|
85
37
|
Effect.mapError(
|
|
86
|
-
(error) => new CallError({
|
|
38
|
+
(error) => new CallError({
|
|
39
|
+
target,
|
|
40
|
+
kind: "handler",
|
|
41
|
+
reason: `app failed to load (${error.message})`,
|
|
42
|
+
}),
|
|
87
43
|
),
|
|
88
44
|
);
|
|
89
45
|
|
|
46
|
+
const settle = (target: string, outcome: CallOutcome): Effect.Effect<unknown, CallError> =>
|
|
47
|
+
outcome.ok
|
|
48
|
+
? Effect.succeed(outcome.value)
|
|
49
|
+
: Effect.fail(new CallError({
|
|
50
|
+
target,
|
|
51
|
+
kind: outcome.kind,
|
|
52
|
+
reason: outcome.reason,
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
const settleDiscovery = (
|
|
56
|
+
segment: string,
|
|
57
|
+
outcome: DiscoverOutcome,
|
|
58
|
+
) => outcome.ok
|
|
59
|
+
? Effect.succeed(outcome.value)
|
|
60
|
+
: Effect.fail(new DiscoverError({ segment, reason: outcome.reason }));
|
|
61
|
+
|
|
90
62
|
return {
|
|
91
63
|
manifest: () => Effect.map(loaded(), ({ result }) => result),
|
|
92
|
-
call: (
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
catch: (error) =>
|
|
108
|
-
new CallError({
|
|
109
|
-
target: name,
|
|
110
|
-
reason: `handler failed (${error instanceof Error ? error.message : String(error)})`,
|
|
111
|
-
}),
|
|
112
|
-
});
|
|
113
|
-
}),
|
|
114
|
-
discover: (name, _binding) =>
|
|
115
|
-
Effect.fail(new CallError({ target: name, reason: "no dynamic tools yet" })),
|
|
64
|
+
call: (address, input, ctx, detail) =>
|
|
65
|
+
registryFor(address).pipe(
|
|
66
|
+
Effect.flatMap((registry) =>
|
|
67
|
+
Effect.promise(() => callTool(registry, address, input, ctx, detail)),
|
|
68
|
+
),
|
|
69
|
+
Effect.flatMap((outcome) => settle(address, outcome)),
|
|
70
|
+
),
|
|
71
|
+
discover: (segment, ctx) =>
|
|
72
|
+
registryFor(segment).pipe(
|
|
73
|
+
Effect.mapError((error) => new DiscoverError({ segment, reason: error.reason })),
|
|
74
|
+
Effect.flatMap((registry) =>
|
|
75
|
+
Effect.promise(() => discoverTool(registry, segment, ctx)),
|
|
76
|
+
),
|
|
77
|
+
Effect.flatMap((outcome) => settleDiscovery(segment, outcome)),
|
|
78
|
+
),
|
|
116
79
|
};
|
|
117
80
|
};
|