@lunora/mcp 1.0.0-alpha.3 → 1.0.0-alpha.30
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/LICENSE.md +6 -0
- package/README.md +41 -8
- package/dist/bin.mjs +14 -2
- package/dist/index.d.mts +215 -11
- package/dist/index.d.ts +215 -11
- package/dist/index.mjs +5 -2
- package/dist/packem_shared/AGENT_RUN_INPUT_SCHEMA-DWobae3P.mjs +168 -0
- package/dist/packem_shared/READ_ONLY_TOOL_DEFINITIONS-CWb_wFd5.mjs +204 -0
- package/dist/packem_shared/{connectStdio-C_mvQBs2.mjs → connectStdio-bkJcaXYE.mjs} +16 -4
- package/dist/packem_shared/createMcpFetchHandler-DfzBSLrm.mjs +14 -0
- package/dist/packem_shared/createPaidMcpServer-b-qp_aEF.mjs +93 -0
- package/package.json +4 -2
- package/dist/packem_shared/TOOL_DEFINITIONS-Dpiu38ji.mjs +0 -112
package/LICENSE.md
CHANGED
|
@@ -103,3 +103,9 @@ Unless required by applicable law or agreed to in writing, software distributed
|
|
|
103
103
|
under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
|
|
104
104
|
CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
|
105
105
|
specific language governing permissions and limitations under the License.
|
|
106
|
+
|
|
107
|
+
<!-- DEPENDENCIES -->
|
|
108
|
+
<!-- /DEPENDENCIES -->
|
|
109
|
+
|
|
110
|
+
<!-- TYPE_DEPENDENCIES -->
|
|
111
|
+
<!-- /TYPE_DEPENDENCIES -->
|
package/README.md
CHANGED
|
@@ -40,14 +40,16 @@ Part of the [Lunora](https://github.com/anolilab/lunora) framework — a type-sa
|
|
|
40
40
|
|
|
41
41
|
## Tools
|
|
42
42
|
|
|
43
|
-
| Tool | Description
|
|
44
|
-
| ---------------------------- |
|
|
45
|
-
| `lunora_list_functions` | List the deployment's public functions (queries, mutations, actions) with their kinds.
|
|
46
|
-
| `lunora_list_tables` | List the deployment's `.global()` tables with their row counts.
|
|
47
|
-
| `lunora_get_function_schema` | Return a function's argument descriptors and kind by path, so a caller can construct a valid arguments object.
|
|
48
|
-
| `lunora_run_query` | Run a query and return its result. Read-only.
|
|
49
|
-
| `lunora_run_mutation` | Run a mutation and return its result. Writes data — use with care.
|
|
50
|
-
| `lunora_run_action` | Run an action and return its result. May call external services.
|
|
43
|
+
| Tool | Description |
|
|
44
|
+
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
45
|
+
| `lunora_list_functions` | List the deployment's public functions (queries, mutations, actions) with their kinds. |
|
|
46
|
+
| `lunora_list_tables` | List the deployment's `.global()` tables with their row counts. |
|
|
47
|
+
| `lunora_get_function_schema` | Return a function's argument descriptors and kind by path, so a caller can construct a valid arguments object. |
|
|
48
|
+
| `lunora_run_query` | Run a query and return its result. Read-only. |
|
|
49
|
+
| `lunora_run_mutation` | Run a mutation and return its result. Writes data — use with care. |
|
|
50
|
+
| `lunora_run_action` | Run an action and return its result. May call external services. |
|
|
51
|
+
| `agent_<name>` | Start a durable [`@lunora/agent`](https://www.npmjs.com/package/@lunora/agent) run and await its answer. One tool per exposed agent. Requires agents enabled. |
|
|
52
|
+
| `lunora_agent_status` | Poll a running agent by `threadKey` and return its answer once finished. Requires agents enabled. |
|
|
51
53
|
|
|
52
54
|
### Recommended agent flow
|
|
53
55
|
|
|
@@ -105,6 +107,36 @@ const server = createLunoraMcpServer({ url: "https://app.example.workers.dev", t
|
|
|
105
107
|
await server.connect(myTransport);
|
|
106
108
|
```
|
|
107
109
|
|
|
110
|
+
## Expose an agent
|
|
111
|
+
|
|
112
|
+
A deployment's durable [`@lunora/agent`](https://www.npmjs.com/package/@lunora/agent) agents can be fronted as MCP tools. This is **opt-in and fail-closed**, mirroring `allowWrites`: starting an agent run is a side effect, so the agent tools are omitted from the advertised list _and_ refused at dispatch unless you explicitly enable them. `@lunora/mcp` takes no dependency on `@lunora/agent` — it reaches the agent's public `agents:agentRun` mutation over RPC like any other function.
|
|
113
|
+
|
|
114
|
+
Enable it with two env vars (or the matching `createLunoraMcpServer` options):
|
|
115
|
+
|
|
116
|
+
- `LUNORA_MCP_ALLOW_AGENTS` — set to `1`/`true`/`yes`/`on` to expose the agent tools. Default: agents disabled.
|
|
117
|
+
- `LUNORA_MCP_AGENTS` — a `;`-separated list of `name:description` pairs selecting which agents to expose, e.g. `"support:Support questions;billing:Billing help"`.
|
|
118
|
+
- `LUNORA_MCP_AGENT_TIMEOUT_MS` (optional) — wall-clock budget a single `agent_<name>` call awaits before returning a pending result to poll.
|
|
119
|
+
|
|
120
|
+
```jsonc
|
|
121
|
+
{
|
|
122
|
+
"mcpServers": {
|
|
123
|
+
"lunora": {
|
|
124
|
+
"command": "lunora-mcp",
|
|
125
|
+
"env": {
|
|
126
|
+
"LUNORA_URL": "https://app.example.workers.dev",
|
|
127
|
+
"LUNORA_ADMIN_TOKEN": "...",
|
|
128
|
+
"LUNORA_MCP_ALLOW_AGENTS": "1",
|
|
129
|
+
"LUNORA_MCP_AGENTS": "support:Support questions;billing:Billing help",
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Each exposed agent gets an `agent_<name>` tool taking `prompt` (required), an optional `threadKey` (reuse to continue a conversation; omit to start a new thread), and an optional `title`. The tool starts a durable run and awaits it up to the timeout budget; if the run outlasts the budget it returns a pending result whose `threadKey` you feed to the generic `lunora_agent_status` tool to poll for the final answer.
|
|
137
|
+
|
|
138
|
+
Runs are **owner-scoped** to the identity the configured token resolves to. Grant a **least-privilege** token mapped to a bot identity so an agent's threads stay isolated per deployment — never the admin token.
|
|
139
|
+
|
|
108
140
|
> This README covers the basics. For the full API, options, and guides, see the **[documentation](https://lunora.sh/docs)**.
|
|
109
141
|
|
|
110
142
|
## Related
|
|
@@ -112,6 +144,7 @@ await server.connect(myTransport);
|
|
|
112
144
|
- [`@lunora/client`](https://www.npmjs.com/package/@lunora/client) — the HTTP RPC client backing every tool.
|
|
113
145
|
- [`@lunora/cli`](https://www.npmjs.com/package/@lunora/cli) — deploy the app the server introspects and invokes.
|
|
114
146
|
- [`@lunora/server`](https://www.npmjs.com/package/@lunora/server) — defines the queries, mutations, and actions the tools call.
|
|
147
|
+
- [`@lunora/agent`](https://www.npmjs.com/package/@lunora/agent) — the durable agents the `agent_<name>` tools front over RPC.
|
|
115
148
|
|
|
116
149
|
## Supported Node.js Versions
|
|
117
150
|
|
package/dist/bin.mjs
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { parseAgentsEnv } from './packem_shared/AGENT_RUN_INPUT_SCHEMA-DWobae3P.mjs';
|
|
3
|
+
import { connectStdio } from './packem_shared/connectStdio-bkJcaXYE.mjs';
|
|
3
4
|
|
|
5
|
+
const ENABLED_ENV_VALUES = /* @__PURE__ */ new Set(["1", "on", "true", "yes"]);
|
|
6
|
+
const isEnvEnabled = (value) => value !== void 0 && ENABLED_ENV_VALUES.has(value.trim().toLowerCase());
|
|
4
7
|
class BinError extends Error {
|
|
5
8
|
code;
|
|
6
9
|
constructor(message, code) {
|
|
@@ -19,8 +22,17 @@ const runBin = async (environment, dependencies = {}) => {
|
|
|
19
22
|
writeError("lunora-mcp: LUNORA_URL environment variable is required\n");
|
|
20
23
|
throw new BinError("LUNORA_URL environment variable is required", 1);
|
|
21
24
|
}
|
|
25
|
+
const rawTimeout = Number(environment.LUNORA_MCP_AGENT_TIMEOUT_MS);
|
|
26
|
+
const agentMaxWaitMs = Number.isFinite(rawTimeout) && rawTimeout > 0 ? rawTimeout : void 0;
|
|
22
27
|
try {
|
|
23
|
-
await connect({
|
|
28
|
+
await connect({
|
|
29
|
+
agents: parseAgentsEnv(environment.LUNORA_MCP_AGENTS),
|
|
30
|
+
allowAgents: isEnvEnabled(environment.LUNORA_MCP_ALLOW_AGENTS),
|
|
31
|
+
allowWrites: isEnvEnabled(environment.LUNORA_MCP_ALLOW_WRITES),
|
|
32
|
+
token: environment.LUNORA_ADMIN_TOKEN,
|
|
33
|
+
url,
|
|
34
|
+
...agentMaxWaitMs === void 0 ? {} : { agentMaxWaitMs }
|
|
35
|
+
});
|
|
24
36
|
} catch (error) {
|
|
25
37
|
const message = error instanceof Error ? error.message : String(error);
|
|
26
38
|
writeError(`lunora-mcp: failed to start — ${message}
|
package/dist/index.d.mts
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { LunoraClient } from '@lunora/client';
|
|
2
2
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
3
|
+
import '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
|
|
4
|
+
import { X402ChargeConfig, X402Price } from '@lunora/x402/charge';
|
|
5
|
+
import { Tool } from '@modelcontextprotocol/sdk/types.js';
|
|
6
|
+
/**
|
|
7
|
+
* The tool surface this MCP server exposes. Each tool maps onto a method the
|
|
8
|
+
* `LunoraClient` already provides, so an AI agent can introspect a deployment
|
|
9
|
+
* (functions, global tables) and invoke its functions over HTTP RPC.
|
|
10
|
+
*
|
|
11
|
+
* Definitions and dispatch live here — separate from the server wiring — so the
|
|
12
|
+
* behaviour is unit-testable against a mock client without driving a transport.
|
|
13
|
+
*/
|
|
14
|
+
/** A JSON-Schema object describing a tool's arguments, per the MCP spec. */
|
|
11
15
|
interface ToolInputSchema {
|
|
12
16
|
properties: Record<string, unknown>;
|
|
13
17
|
required?: ReadonlyArray<string>;
|
|
@@ -18,6 +22,7 @@ interface ToolDefinition {
|
|
|
18
22
|
inputSchema: ToolInputSchema;
|
|
19
23
|
name: string;
|
|
20
24
|
}
|
|
25
|
+
/** The MCP `CallToolResult` shape this server returns. */
|
|
21
26
|
interface ToolResult {
|
|
22
27
|
content: {
|
|
23
28
|
text: string;
|
|
@@ -25,6 +30,205 @@ interface ToolResult {
|
|
|
25
30
|
}[];
|
|
26
31
|
isError?: boolean;
|
|
27
32
|
}
|
|
28
|
-
|
|
29
|
-
declare const
|
|
30
|
-
|
|
33
|
+
/** The read-only tool surface: introspection + query. Always exposed. */
|
|
34
|
+
declare const READ_ONLY_TOOL_DEFINITIONS: ReadonlyArray<ToolDefinition>;
|
|
35
|
+
/** The write tool surface (mutations + actions). Exposed ONLY when writes are enabled. */
|
|
36
|
+
declare const WRITE_TOOL_DEFINITIONS: ReadonlyArray<ToolDefinition>;
|
|
37
|
+
/**
|
|
38
|
+
* The tools this server advertises. When `allowWrites` is false (the default),
|
|
39
|
+
* only the read-only surface is exposed — the mutation/action tools are omitted
|
|
40
|
+
* from `ListTools` entirely, so an AI agent can't invoke a write it can't see.
|
|
41
|
+
*/
|
|
42
|
+
declare const toolDefinitions: (allowWrites: boolean) => ReadonlyArray<ToolDefinition>;
|
|
43
|
+
/**
|
|
44
|
+
* Dispatch a tool call against `client`. Unknown tools and thrown errors are
|
|
45
|
+
* returned as `isError` results (rather than rejections) so the calling model
|
|
46
|
+
* sees the failure as tool output, per the MCP convention.
|
|
47
|
+
*
|
|
48
|
+
* `allowWrites` gates the mutation/action tools: when false (the default) a call
|
|
49
|
+
* to a write tool is refused even if the client somehow names it, so the
|
|
50
|
+
* read-only guarantee holds at dispatch, not just in the advertised tool list.
|
|
51
|
+
*/
|
|
52
|
+
declare const callTool: (client: LunoraClient, name: string, input: Record<string, unknown>, allowWrites?: boolean) => Promise<ToolResult>;
|
|
53
|
+
/**
|
|
54
|
+
* Agent exposure for the MCP server: a durable `@lunora/agent` run fronted as an
|
|
55
|
+
* MCP tool an external agent can call. The capability boundary is the MCP-server
|
|
56
|
+
* process + its token, so WHICH agents are exposed is config on the server (like
|
|
57
|
+
* `allowWrites`), not on `defineAgent` — keeping `@lunora/agent` codegen
|
|
58
|
+
* byte-identical.
|
|
59
|
+
*/
|
|
60
|
+
interface McpAgentExposure {
|
|
61
|
+
/** What the agent does — shown to the calling model, which decides from it. */
|
|
62
|
+
description: string;
|
|
63
|
+
/** The agent's export name (its `ctx.agents.<name>` / `AGENT_<NAME>` binding). */
|
|
64
|
+
name: string;
|
|
65
|
+
/** Override the model-facing tool name (default `agent_<name>`). */
|
|
66
|
+
toolName?: string;
|
|
67
|
+
}
|
|
68
|
+
/** The generic status/poll tool advertised alongside the per-agent tools. */
|
|
69
|
+
declare const AGENT_STATUS_TOOL_NAME = "lunora_agent_status";
|
|
70
|
+
/**
|
|
71
|
+
* The uniform input schema every agent tool advertises. Agents share ONE run
|
|
72
|
+
* input (`@lunora/agent` has no per-agent validator), so there is nothing to
|
|
73
|
+
* derive per agent — a single static schema is reused for every agent tool.
|
|
74
|
+
*/
|
|
75
|
+
declare const AGENT_RUN_INPUT_SCHEMA: ToolInputSchema;
|
|
76
|
+
/**
|
|
77
|
+
* Parse `LUNORA_MCP_AGENTS` — a `;`-separated list of `name:description` pairs,
|
|
78
|
+
* e.g. `"support:Handles support questions;billing:Billing help"`. The
|
|
79
|
+
* description may itself contain colons (only the FIRST colon splits). Blank
|
|
80
|
+
* entries and entries with an empty name/description are skipped.
|
|
81
|
+
*/
|
|
82
|
+
declare const parseAgentsEnv: (raw: string | undefined) => McpAgentExposure[];
|
|
83
|
+
/**
|
|
84
|
+
* The tools this module advertises. Fail-closed: only the boolean `true` opts
|
|
85
|
+
* in (an env-plumbed caller could pass a truthy string), and the tools appear
|
|
86
|
+
* ONLY when at least one agent is exposed — so an agent-free or non-opted-in
|
|
87
|
+
* server never lists them.
|
|
88
|
+
*/
|
|
89
|
+
declare const agentToolDefinitions: (exposures: ReadonlyArray<McpAgentExposure>, allowAgents: boolean) => ReadonlyArray<ToolDefinition>;
|
|
90
|
+
/** Options threaded into a single agent tool dispatch. */
|
|
91
|
+
interface CallAgentToolOptions {
|
|
92
|
+
/** Opt-in gate — must be exactly `true` or the call is refused fail-closed. */
|
|
93
|
+
allowAgents: boolean;
|
|
94
|
+
/** The exposures advertised by this server. */
|
|
95
|
+
exposures: ReadonlyArray<McpAgentExposure>;
|
|
96
|
+
/** Wall-clock budget a single call awaits before returning a pending result. */
|
|
97
|
+
maxWaitMs?: number;
|
|
98
|
+
/** Delay between thread-status polls. */
|
|
99
|
+
pollIntervalMs?: number;
|
|
100
|
+
/** Test seam replacing the between-poll wait; production uses a real timer. */
|
|
101
|
+
wait?: (ms: number) => Promise<void>;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Dispatch an agent tool call: start a durable run via `agents:agentRun`, then
|
|
105
|
+
* await-with-timeout — poll `agents:agentThread` until terminal (returning the
|
|
106
|
+
* final answer from `agents:agentMessages`) or, on budget exhaustion, return a
|
|
107
|
+
* NON-error pending payload the caller resumes with `lunora_agent_status`.
|
|
108
|
+
*
|
|
109
|
+
* Fail-closed: refused at dispatch unless `allowAgents === true`, mirroring the
|
|
110
|
+
* `allowWrites` guard — starting a run is a side effect and must not ride the
|
|
111
|
+
* read-only default.
|
|
112
|
+
*/
|
|
113
|
+
declare const callAgentTool: (client: LunoraClient, name: string, input: Record<string, unknown>, options: CallAgentToolOptions) => Promise<ToolResult>;
|
|
114
|
+
interface LunoraMcpServerOptions {
|
|
115
|
+
/** Wall-clock budget a single agent tool call awaits before returning a pending result. */
|
|
116
|
+
agentMaxWaitMs?: number;
|
|
117
|
+
/** Delay between agent thread-status polls. */
|
|
118
|
+
agentPollIntervalMs?: number;
|
|
119
|
+
/** The agents this server fronts as MCP tools (see `allowAgents`). */
|
|
120
|
+
agents?: ReadonlyArray<McpAgentExposure>;
|
|
121
|
+
/**
|
|
122
|
+
* Expose the per-agent tools (`agent_<name>` + the generic
|
|
123
|
+
* `lunora_agent_status`). Defaults to `false`, mirroring `allowWrites`:
|
|
124
|
+
* starting a durable agent run is a side effect, so the agent tools are
|
|
125
|
+
* omitted from the advertised list AND refused at dispatch unless explicitly
|
|
126
|
+
* opted in. Only takes effect together with a non-empty `agents` list.
|
|
127
|
+
*/
|
|
128
|
+
allowAgents?: boolean;
|
|
129
|
+
/**
|
|
130
|
+
* Expose the write tools (`lunora_run_mutation` / `lunora_run_action`).
|
|
131
|
+
* Defaults to `false`: the server is READ-ONLY unless explicitly opted in,
|
|
132
|
+
* so a prompt-injected or misaligned agent can't mutate the deployment with
|
|
133
|
+
* the configured token. When false the write tools are omitted from the
|
|
134
|
+
* advertised tool list AND refused at dispatch.
|
|
135
|
+
*/
|
|
136
|
+
allowWrites?: boolean;
|
|
137
|
+
/**
|
|
138
|
+
* Pre-built client (test injection). When omitted a `LunoraClient` is
|
|
139
|
+
* created from `url`/`token`/`fetch`.
|
|
140
|
+
*/
|
|
141
|
+
client?: LunoraClient;
|
|
142
|
+
/** `fetch` implementation; defaults to the ambient global. */
|
|
143
|
+
fetch?: typeof fetch;
|
|
144
|
+
/**
|
|
145
|
+
* Bearer token sent on every RPC. This must be the deployment's **admin
|
|
146
|
+
* bearer**: the introspection/allowlist path every tool depends on
|
|
147
|
+
* (`lunora_list_functions`, `lunora_list_tables`, and the `assertRunnable`
|
|
148
|
+
* precheck that runs before every `run` tool) hits admin-gated
|
|
149
|
+
* `/_lunora/admin/*` routes, so no scoped/app token works today — it would
|
|
150
|
+
* 403 (`ADMIN_FORBIDDEN`) on the first tool call. The read-only guarantee is
|
|
151
|
+
* therefore NOT enforced by the token's scope; it is enforced in-process via
|
|
152
|
+
* `allowWrites: false` (the default), which omits the write tools from the
|
|
153
|
+
* advertised list and refuses them at dispatch.
|
|
154
|
+
*/
|
|
155
|
+
token?: string;
|
|
156
|
+
/** Base URL of the deployed Lunora Worker. Required unless `client` is given. */
|
|
157
|
+
url?: string;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Build an MCP `Server` whose tools talk to a Lunora deployment. The server is
|
|
161
|
+
* transport-agnostic — call `.connect(transport)` yourself, or use
|
|
162
|
+
* `connectStdio` for the common stdio case.
|
|
163
|
+
*
|
|
164
|
+
* Tool calls are dispatched through `callTool`, which the deployment reaches
|
|
165
|
+
* over HTTP RPC. No WebSocket is opened (the tools never subscribe), so this is
|
|
166
|
+
* safe to run as a short-lived stdio process.
|
|
167
|
+
*/
|
|
168
|
+
declare const createLunoraMcpServer: (options: LunoraMcpServerOptions) => Server;
|
|
169
|
+
/**
|
|
170
|
+
* Build the server and connect it over stdio — the transport MCP clients use
|
|
171
|
+
* when they spawn the `lunora-mcp` binary. Resolves once the transport is
|
|
172
|
+
* connected; the process then stays alive serving requests.
|
|
173
|
+
*/
|
|
174
|
+
declare const connectStdio: (options: LunoraMcpServerOptions) => Promise<Server>;
|
|
175
|
+
/** A Web-Standard fetch handler: takes a `Request`, returns the MCP `Response`. */
|
|
176
|
+
type McpFetchHandler = (request: Request) => Promise<Response>;
|
|
177
|
+
/**
|
|
178
|
+
* Build a stateless Streamable-HTTP fetch handler for a Lunora MCP server. Each
|
|
179
|
+
* invocation constructs a fresh proxy server and serves the request through
|
|
180
|
+
* {@link serveStateless}.
|
|
181
|
+
*/
|
|
182
|
+
declare const createMcpFetchHandler: (options: LunoraMcpServerOptions) => McpFetchHandler;
|
|
183
|
+
/** A tool handler: receives the call's `arguments` bag, returns an MCP tool result. */
|
|
184
|
+
type ToolHandler = (arguments_: Record<string, unknown>) => Promise<ToolResult> | ToolResult;
|
|
185
|
+
/** Registration shape for a free tool. */
|
|
186
|
+
interface RegisterToolOptions {
|
|
187
|
+
/** Optional MCP tool annotations (`readOnlyHint`, `title`, …). */
|
|
188
|
+
annotations?: Tool["annotations"];
|
|
189
|
+
/** Human/model-facing description of what the tool does. */
|
|
190
|
+
description: string;
|
|
191
|
+
/** JSON-Schema object describing the tool's arguments. */
|
|
192
|
+
inputSchema: ToolInputSchema;
|
|
193
|
+
/** Unique tool name (the MCP `tools/call` `name`). */
|
|
194
|
+
name: string;
|
|
195
|
+
}
|
|
196
|
+
/** Registration shape for a paid tool: a {@link RegisterToolOptions} plus its USD price. */
|
|
197
|
+
interface RegisterPaidToolOptions extends RegisterToolOptions {
|
|
198
|
+
/** USD price per call (e.g. `"$0.05"`), charged via x402 before dispatch. */
|
|
199
|
+
price: X402Price;
|
|
200
|
+
}
|
|
201
|
+
/** x402 settlement vocabulary shared by every paid tool (network, recipient, facilitator); price is per-tool. */
|
|
202
|
+
type PaidMcpChargeConfig = Omit<X402ChargeConfig, "price">;
|
|
203
|
+
/** Config for `createPaidMcpServer`. */
|
|
204
|
+
interface PaidMcpServerConfig {
|
|
205
|
+
/** The worker-level x402 charge config; each paid tool supplies only its own `price`. */
|
|
206
|
+
charge: PaidMcpChargeConfig;
|
|
207
|
+
/** Name/version advertised in the MCP `initialize` handshake. Defaults to `lunora-paid-mcp`. */
|
|
208
|
+
serverInfo?: {
|
|
209
|
+
name: string;
|
|
210
|
+
version: string;
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
/** A paid MCP server: register free/paid tools, then serve over Streamable HTTP. */
|
|
214
|
+
interface PaidMcpServer {
|
|
215
|
+
/** The Streamable-HTTP fetch handler; gates each paid `tools/call` behind x402. */
|
|
216
|
+
readonly fetchHandler: McpFetchHandler;
|
|
217
|
+
/** Register a **paid** tool: its dispatch runs the x402 charge middleware first. */
|
|
218
|
+
paidTool: (options: RegisterPaidToolOptions, handler: ToolHandler) => void;
|
|
219
|
+
/** Register a **free** tool (coexists with paid tools on the same server). */
|
|
220
|
+
tool: (options: RegisterToolOptions, handler: ToolHandler) => void;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Create a paid MCP server. Register free tools with `tool()` and priced tools
|
|
224
|
+
* with `paidTool()` (they coexist), then serve `fetchHandler` over HTTP.
|
|
225
|
+
*
|
|
226
|
+
* The server is **stateless**: `fetchHandler` builds a fresh `Server` per
|
|
227
|
+
* request (reading the live tool registry), so tools registered before the
|
|
228
|
+
* first request are all visible. Each priced tool memoises one initialised
|
|
229
|
+
* `ChargeMiddleware` (keyed by tool name, baking that tool's price and naming
|
|
230
|
+
* the tool as the challenge `resource`); a failed init is not cached, so a
|
|
231
|
+
* transient facilitator outage retries on the next call.
|
|
232
|
+
*/
|
|
233
|
+
declare const createPaidMcpServer: (config: PaidMcpServerConfig) => PaidMcpServer;
|
|
234
|
+
export { AGENT_RUN_INPUT_SCHEMA, AGENT_STATUS_TOOL_NAME, type CallAgentToolOptions, type LunoraMcpServerOptions, type McpAgentExposure, type McpFetchHandler, type PaidMcpChargeConfig, type PaidMcpServer, type PaidMcpServerConfig, READ_ONLY_TOOL_DEFINITIONS, type RegisterPaidToolOptions, type RegisterToolOptions, type ToolDefinition, type ToolHandler, type ToolInputSchema, type ToolResult, WRITE_TOOL_DEFINITIONS, agentToolDefinitions, callAgentTool, callTool, connectStdio, createLunoraMcpServer, createMcpFetchHandler, createPaidMcpServer, parseAgentsEnv, toolDefinitions };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { LunoraClient } from '@lunora/client';
|
|
2
2
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
3
|
+
import '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
|
|
4
|
+
import { X402ChargeConfig, X402Price } from '@lunora/x402/charge';
|
|
5
|
+
import { Tool } from '@modelcontextprotocol/sdk/types.js';
|
|
6
|
+
/**
|
|
7
|
+
* The tool surface this MCP server exposes. Each tool maps onto a method the
|
|
8
|
+
* `LunoraClient` already provides, so an AI agent can introspect a deployment
|
|
9
|
+
* (functions, global tables) and invoke its functions over HTTP RPC.
|
|
10
|
+
*
|
|
11
|
+
* Definitions and dispatch live here — separate from the server wiring — so the
|
|
12
|
+
* behaviour is unit-testable against a mock client without driving a transport.
|
|
13
|
+
*/
|
|
14
|
+
/** A JSON-Schema object describing a tool's arguments, per the MCP spec. */
|
|
11
15
|
interface ToolInputSchema {
|
|
12
16
|
properties: Record<string, unknown>;
|
|
13
17
|
required?: ReadonlyArray<string>;
|
|
@@ -18,6 +22,7 @@ interface ToolDefinition {
|
|
|
18
22
|
inputSchema: ToolInputSchema;
|
|
19
23
|
name: string;
|
|
20
24
|
}
|
|
25
|
+
/** The MCP `CallToolResult` shape this server returns. */
|
|
21
26
|
interface ToolResult {
|
|
22
27
|
content: {
|
|
23
28
|
text: string;
|
|
@@ -25,6 +30,205 @@ interface ToolResult {
|
|
|
25
30
|
}[];
|
|
26
31
|
isError?: boolean;
|
|
27
32
|
}
|
|
28
|
-
|
|
29
|
-
declare const
|
|
30
|
-
|
|
33
|
+
/** The read-only tool surface: introspection + query. Always exposed. */
|
|
34
|
+
declare const READ_ONLY_TOOL_DEFINITIONS: ReadonlyArray<ToolDefinition>;
|
|
35
|
+
/** The write tool surface (mutations + actions). Exposed ONLY when writes are enabled. */
|
|
36
|
+
declare const WRITE_TOOL_DEFINITIONS: ReadonlyArray<ToolDefinition>;
|
|
37
|
+
/**
|
|
38
|
+
* The tools this server advertises. When `allowWrites` is false (the default),
|
|
39
|
+
* only the read-only surface is exposed — the mutation/action tools are omitted
|
|
40
|
+
* from `ListTools` entirely, so an AI agent can't invoke a write it can't see.
|
|
41
|
+
*/
|
|
42
|
+
declare const toolDefinitions: (allowWrites: boolean) => ReadonlyArray<ToolDefinition>;
|
|
43
|
+
/**
|
|
44
|
+
* Dispatch a tool call against `client`. Unknown tools and thrown errors are
|
|
45
|
+
* returned as `isError` results (rather than rejections) so the calling model
|
|
46
|
+
* sees the failure as tool output, per the MCP convention.
|
|
47
|
+
*
|
|
48
|
+
* `allowWrites` gates the mutation/action tools: when false (the default) a call
|
|
49
|
+
* to a write tool is refused even if the client somehow names it, so the
|
|
50
|
+
* read-only guarantee holds at dispatch, not just in the advertised tool list.
|
|
51
|
+
*/
|
|
52
|
+
declare const callTool: (client: LunoraClient, name: string, input: Record<string, unknown>, allowWrites?: boolean) => Promise<ToolResult>;
|
|
53
|
+
/**
|
|
54
|
+
* Agent exposure for the MCP server: a durable `@lunora/agent` run fronted as an
|
|
55
|
+
* MCP tool an external agent can call. The capability boundary is the MCP-server
|
|
56
|
+
* process + its token, so WHICH agents are exposed is config on the server (like
|
|
57
|
+
* `allowWrites`), not on `defineAgent` — keeping `@lunora/agent` codegen
|
|
58
|
+
* byte-identical.
|
|
59
|
+
*/
|
|
60
|
+
interface McpAgentExposure {
|
|
61
|
+
/** What the agent does — shown to the calling model, which decides from it. */
|
|
62
|
+
description: string;
|
|
63
|
+
/** The agent's export name (its `ctx.agents.<name>` / `AGENT_<NAME>` binding). */
|
|
64
|
+
name: string;
|
|
65
|
+
/** Override the model-facing tool name (default `agent_<name>`). */
|
|
66
|
+
toolName?: string;
|
|
67
|
+
}
|
|
68
|
+
/** The generic status/poll tool advertised alongside the per-agent tools. */
|
|
69
|
+
declare const AGENT_STATUS_TOOL_NAME = "lunora_agent_status";
|
|
70
|
+
/**
|
|
71
|
+
* The uniform input schema every agent tool advertises. Agents share ONE run
|
|
72
|
+
* input (`@lunora/agent` has no per-agent validator), so there is nothing to
|
|
73
|
+
* derive per agent — a single static schema is reused for every agent tool.
|
|
74
|
+
*/
|
|
75
|
+
declare const AGENT_RUN_INPUT_SCHEMA: ToolInputSchema;
|
|
76
|
+
/**
|
|
77
|
+
* Parse `LUNORA_MCP_AGENTS` — a `;`-separated list of `name:description` pairs,
|
|
78
|
+
* e.g. `"support:Handles support questions;billing:Billing help"`. The
|
|
79
|
+
* description may itself contain colons (only the FIRST colon splits). Blank
|
|
80
|
+
* entries and entries with an empty name/description are skipped.
|
|
81
|
+
*/
|
|
82
|
+
declare const parseAgentsEnv: (raw: string | undefined) => McpAgentExposure[];
|
|
83
|
+
/**
|
|
84
|
+
* The tools this module advertises. Fail-closed: only the boolean `true` opts
|
|
85
|
+
* in (an env-plumbed caller could pass a truthy string), and the tools appear
|
|
86
|
+
* ONLY when at least one agent is exposed — so an agent-free or non-opted-in
|
|
87
|
+
* server never lists them.
|
|
88
|
+
*/
|
|
89
|
+
declare const agentToolDefinitions: (exposures: ReadonlyArray<McpAgentExposure>, allowAgents: boolean) => ReadonlyArray<ToolDefinition>;
|
|
90
|
+
/** Options threaded into a single agent tool dispatch. */
|
|
91
|
+
interface CallAgentToolOptions {
|
|
92
|
+
/** Opt-in gate — must be exactly `true` or the call is refused fail-closed. */
|
|
93
|
+
allowAgents: boolean;
|
|
94
|
+
/** The exposures advertised by this server. */
|
|
95
|
+
exposures: ReadonlyArray<McpAgentExposure>;
|
|
96
|
+
/** Wall-clock budget a single call awaits before returning a pending result. */
|
|
97
|
+
maxWaitMs?: number;
|
|
98
|
+
/** Delay between thread-status polls. */
|
|
99
|
+
pollIntervalMs?: number;
|
|
100
|
+
/** Test seam replacing the between-poll wait; production uses a real timer. */
|
|
101
|
+
wait?: (ms: number) => Promise<void>;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Dispatch an agent tool call: start a durable run via `agents:agentRun`, then
|
|
105
|
+
* await-with-timeout — poll `agents:agentThread` until terminal (returning the
|
|
106
|
+
* final answer from `agents:agentMessages`) or, on budget exhaustion, return a
|
|
107
|
+
* NON-error pending payload the caller resumes with `lunora_agent_status`.
|
|
108
|
+
*
|
|
109
|
+
* Fail-closed: refused at dispatch unless `allowAgents === true`, mirroring the
|
|
110
|
+
* `allowWrites` guard — starting a run is a side effect and must not ride the
|
|
111
|
+
* read-only default.
|
|
112
|
+
*/
|
|
113
|
+
declare const callAgentTool: (client: LunoraClient, name: string, input: Record<string, unknown>, options: CallAgentToolOptions) => Promise<ToolResult>;
|
|
114
|
+
interface LunoraMcpServerOptions {
|
|
115
|
+
/** Wall-clock budget a single agent tool call awaits before returning a pending result. */
|
|
116
|
+
agentMaxWaitMs?: number;
|
|
117
|
+
/** Delay between agent thread-status polls. */
|
|
118
|
+
agentPollIntervalMs?: number;
|
|
119
|
+
/** The agents this server fronts as MCP tools (see `allowAgents`). */
|
|
120
|
+
agents?: ReadonlyArray<McpAgentExposure>;
|
|
121
|
+
/**
|
|
122
|
+
* Expose the per-agent tools (`agent_<name>` + the generic
|
|
123
|
+
* `lunora_agent_status`). Defaults to `false`, mirroring `allowWrites`:
|
|
124
|
+
* starting a durable agent run is a side effect, so the agent tools are
|
|
125
|
+
* omitted from the advertised list AND refused at dispatch unless explicitly
|
|
126
|
+
* opted in. Only takes effect together with a non-empty `agents` list.
|
|
127
|
+
*/
|
|
128
|
+
allowAgents?: boolean;
|
|
129
|
+
/**
|
|
130
|
+
* Expose the write tools (`lunora_run_mutation` / `lunora_run_action`).
|
|
131
|
+
* Defaults to `false`: the server is READ-ONLY unless explicitly opted in,
|
|
132
|
+
* so a prompt-injected or misaligned agent can't mutate the deployment with
|
|
133
|
+
* the configured token. When false the write tools are omitted from the
|
|
134
|
+
* advertised tool list AND refused at dispatch.
|
|
135
|
+
*/
|
|
136
|
+
allowWrites?: boolean;
|
|
137
|
+
/**
|
|
138
|
+
* Pre-built client (test injection). When omitted a `LunoraClient` is
|
|
139
|
+
* created from `url`/`token`/`fetch`.
|
|
140
|
+
*/
|
|
141
|
+
client?: LunoraClient;
|
|
142
|
+
/** `fetch` implementation; defaults to the ambient global. */
|
|
143
|
+
fetch?: typeof fetch;
|
|
144
|
+
/**
|
|
145
|
+
* Bearer token sent on every RPC. This must be the deployment's **admin
|
|
146
|
+
* bearer**: the introspection/allowlist path every tool depends on
|
|
147
|
+
* (`lunora_list_functions`, `lunora_list_tables`, and the `assertRunnable`
|
|
148
|
+
* precheck that runs before every `run` tool) hits admin-gated
|
|
149
|
+
* `/_lunora/admin/*` routes, so no scoped/app token works today — it would
|
|
150
|
+
* 403 (`ADMIN_FORBIDDEN`) on the first tool call. The read-only guarantee is
|
|
151
|
+
* therefore NOT enforced by the token's scope; it is enforced in-process via
|
|
152
|
+
* `allowWrites: false` (the default), which omits the write tools from the
|
|
153
|
+
* advertised list and refuses them at dispatch.
|
|
154
|
+
*/
|
|
155
|
+
token?: string;
|
|
156
|
+
/** Base URL of the deployed Lunora Worker. Required unless `client` is given. */
|
|
157
|
+
url?: string;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Build an MCP `Server` whose tools talk to a Lunora deployment. The server is
|
|
161
|
+
* transport-agnostic — call `.connect(transport)` yourself, or use
|
|
162
|
+
* `connectStdio` for the common stdio case.
|
|
163
|
+
*
|
|
164
|
+
* Tool calls are dispatched through `callTool`, which the deployment reaches
|
|
165
|
+
* over HTTP RPC. No WebSocket is opened (the tools never subscribe), so this is
|
|
166
|
+
* safe to run as a short-lived stdio process.
|
|
167
|
+
*/
|
|
168
|
+
declare const createLunoraMcpServer: (options: LunoraMcpServerOptions) => Server;
|
|
169
|
+
/**
|
|
170
|
+
* Build the server and connect it over stdio — the transport MCP clients use
|
|
171
|
+
* when they spawn the `lunora-mcp` binary. Resolves once the transport is
|
|
172
|
+
* connected; the process then stays alive serving requests.
|
|
173
|
+
*/
|
|
174
|
+
declare const connectStdio: (options: LunoraMcpServerOptions) => Promise<Server>;
|
|
175
|
+
/** A Web-Standard fetch handler: takes a `Request`, returns the MCP `Response`. */
|
|
176
|
+
type McpFetchHandler = (request: Request) => Promise<Response>;
|
|
177
|
+
/**
|
|
178
|
+
* Build a stateless Streamable-HTTP fetch handler for a Lunora MCP server. Each
|
|
179
|
+
* invocation constructs a fresh proxy server and serves the request through
|
|
180
|
+
* {@link serveStateless}.
|
|
181
|
+
*/
|
|
182
|
+
declare const createMcpFetchHandler: (options: LunoraMcpServerOptions) => McpFetchHandler;
|
|
183
|
+
/** A tool handler: receives the call's `arguments` bag, returns an MCP tool result. */
|
|
184
|
+
type ToolHandler = (arguments_: Record<string, unknown>) => Promise<ToolResult> | ToolResult;
|
|
185
|
+
/** Registration shape for a free tool. */
|
|
186
|
+
interface RegisterToolOptions {
|
|
187
|
+
/** Optional MCP tool annotations (`readOnlyHint`, `title`, …). */
|
|
188
|
+
annotations?: Tool["annotations"];
|
|
189
|
+
/** Human/model-facing description of what the tool does. */
|
|
190
|
+
description: string;
|
|
191
|
+
/** JSON-Schema object describing the tool's arguments. */
|
|
192
|
+
inputSchema: ToolInputSchema;
|
|
193
|
+
/** Unique tool name (the MCP `tools/call` `name`). */
|
|
194
|
+
name: string;
|
|
195
|
+
}
|
|
196
|
+
/** Registration shape for a paid tool: a {@link RegisterToolOptions} plus its USD price. */
|
|
197
|
+
interface RegisterPaidToolOptions extends RegisterToolOptions {
|
|
198
|
+
/** USD price per call (e.g. `"$0.05"`), charged via x402 before dispatch. */
|
|
199
|
+
price: X402Price;
|
|
200
|
+
}
|
|
201
|
+
/** x402 settlement vocabulary shared by every paid tool (network, recipient, facilitator); price is per-tool. */
|
|
202
|
+
type PaidMcpChargeConfig = Omit<X402ChargeConfig, "price">;
|
|
203
|
+
/** Config for `createPaidMcpServer`. */
|
|
204
|
+
interface PaidMcpServerConfig {
|
|
205
|
+
/** The worker-level x402 charge config; each paid tool supplies only its own `price`. */
|
|
206
|
+
charge: PaidMcpChargeConfig;
|
|
207
|
+
/** Name/version advertised in the MCP `initialize` handshake. Defaults to `lunora-paid-mcp`. */
|
|
208
|
+
serverInfo?: {
|
|
209
|
+
name: string;
|
|
210
|
+
version: string;
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
/** A paid MCP server: register free/paid tools, then serve over Streamable HTTP. */
|
|
214
|
+
interface PaidMcpServer {
|
|
215
|
+
/** The Streamable-HTTP fetch handler; gates each paid `tools/call` behind x402. */
|
|
216
|
+
readonly fetchHandler: McpFetchHandler;
|
|
217
|
+
/** Register a **paid** tool: its dispatch runs the x402 charge middleware first. */
|
|
218
|
+
paidTool: (options: RegisterPaidToolOptions, handler: ToolHandler) => void;
|
|
219
|
+
/** Register a **free** tool (coexists with paid tools on the same server). */
|
|
220
|
+
tool: (options: RegisterToolOptions, handler: ToolHandler) => void;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Create a paid MCP server. Register free tools with `tool()` and priced tools
|
|
224
|
+
* with `paidTool()` (they coexist), then serve `fetchHandler` over HTTP.
|
|
225
|
+
*
|
|
226
|
+
* The server is **stateless**: `fetchHandler` builds a fresh `Server` per
|
|
227
|
+
* request (reading the live tool registry), so tools registered before the
|
|
228
|
+
* first request are all visible. Each priced tool memoises one initialised
|
|
229
|
+
* `ChargeMiddleware` (keyed by tool name, baking that tool's price and naming
|
|
230
|
+
* the tool as the challenge `resource`); a failed init is not cached, so a
|
|
231
|
+
* transient facilitator outage retries on the next call.
|
|
232
|
+
*/
|
|
233
|
+
declare const createPaidMcpServer: (config: PaidMcpServerConfig) => PaidMcpServer;
|
|
234
|
+
export { AGENT_RUN_INPUT_SCHEMA, AGENT_STATUS_TOOL_NAME, type CallAgentToolOptions, type LunoraMcpServerOptions, type McpAgentExposure, type McpFetchHandler, type PaidMcpChargeConfig, type PaidMcpServer, type PaidMcpServerConfig, READ_ONLY_TOOL_DEFINITIONS, type RegisterPaidToolOptions, type RegisterToolOptions, type ToolDefinition, type ToolHandler, type ToolInputSchema, type ToolResult, WRITE_TOOL_DEFINITIONS, agentToolDefinitions, callAgentTool, callTool, connectStdio, createLunoraMcpServer, createMcpFetchHandler, createPaidMcpServer, parseAgentsEnv, toolDefinitions };
|
package/dist/index.mjs
CHANGED
|
@@ -1,2 +1,5 @@
|
|
|
1
|
-
export {
|
|
2
|
-
export {
|
|
1
|
+
export { AGENT_RUN_INPUT_SCHEMA, AGENT_STATUS_TOOL_NAME, agentToolDefinitions, callAgentTool, parseAgentsEnv } from './packem_shared/AGENT_RUN_INPUT_SCHEMA-DWobae3P.mjs';
|
|
2
|
+
export { createMcpFetchHandler } from './packem_shared/createMcpFetchHandler-DfzBSLrm.mjs';
|
|
3
|
+
export { createPaidMcpServer } from './packem_shared/createPaidMcpServer-b-qp_aEF.mjs';
|
|
4
|
+
export { connectStdio, createLunoraMcpServer } from './packem_shared/connectStdio-bkJcaXYE.mjs';
|
|
5
|
+
export { READ_ONLY_TOOL_DEFINITIONS, WRITE_TOOL_DEFINITIONS, callTool, toolDefinitions } from './packem_shared/READ_ONLY_TOOL_DEFINITIONS-CWb_wFd5.mjs';
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
const AGENT_RUN_PATH = "agents:agentRun";
|
|
2
|
+
const AGENT_THREAD_PATH = "agents:agentThread";
|
|
3
|
+
const AGENT_MESSAGES_PATH = "agents:agentMessages";
|
|
4
|
+
const AGENT_STATUS_TOOL_NAME = "lunora_agent_status";
|
|
5
|
+
const TERMINAL_STATUSES = /* @__PURE__ */ new Set(["cancelled", "error", "idle"]);
|
|
6
|
+
const DEFAULT_MAX_WAIT_MS = 6e4;
|
|
7
|
+
const DEFAULT_POLL_INTERVAL_MS = 600;
|
|
8
|
+
const AGENT_RUN_INPUT_SCHEMA = {
|
|
9
|
+
properties: {
|
|
10
|
+
prompt: { description: "The task or message for the agent.", type: "string" },
|
|
11
|
+
threadKey: { description: "Reuse to continue a conversation; omit to start a new thread.", type: "string" },
|
|
12
|
+
title: { description: "Optional thread title (first run only).", type: "string" }
|
|
13
|
+
},
|
|
14
|
+
required: ["prompt"],
|
|
15
|
+
type: "object"
|
|
16
|
+
};
|
|
17
|
+
const AGENT_STATUS_INPUT_SCHEMA = {
|
|
18
|
+
properties: {
|
|
19
|
+
threadKey: { description: "The thread key returned by an agent tool call.", type: "string" }
|
|
20
|
+
},
|
|
21
|
+
required: ["threadKey"],
|
|
22
|
+
type: "object"
|
|
23
|
+
};
|
|
24
|
+
const agentToolName = (exposure) => exposure.toolName ?? `agent_${exposure.name}`;
|
|
25
|
+
const parseAgentsEnv = (raw) => {
|
|
26
|
+
if (raw === void 0) {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
const exposures = [];
|
|
30
|
+
for (const entry of raw.split(";")) {
|
|
31
|
+
const trimmed = entry.trim();
|
|
32
|
+
if (trimmed.length === 0) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const separator = trimmed.indexOf(":");
|
|
36
|
+
if (separator <= 0) {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const name = trimmed.slice(0, separator).trim();
|
|
40
|
+
const description = trimmed.slice(separator + 1).trim();
|
|
41
|
+
if (name.length === 0 || description.length === 0) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
exposures.push({ description, name });
|
|
45
|
+
}
|
|
46
|
+
return exposures;
|
|
47
|
+
};
|
|
48
|
+
const agentToolDefinitions = (exposures, allowAgents) => {
|
|
49
|
+
if (allowAgents !== true || exposures.length === 0) {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
const perAgent = exposures.map((exposure) => {
|
|
53
|
+
return {
|
|
54
|
+
description: `${exposure.description} Starts a durable agent run and returns its final answer.`,
|
|
55
|
+
inputSchema: AGENT_RUN_INPUT_SCHEMA,
|
|
56
|
+
name: agentToolName(exposure)
|
|
57
|
+
};
|
|
58
|
+
});
|
|
59
|
+
return [
|
|
60
|
+
...perAgent,
|
|
61
|
+
{
|
|
62
|
+
description: "Check the status of a durable agent run (and its answer if finished) by its threadKey.",
|
|
63
|
+
inputSchema: AGENT_STATUS_INPUT_SCHEMA,
|
|
64
|
+
name: AGENT_STATUS_TOOL_NAME
|
|
65
|
+
}
|
|
66
|
+
];
|
|
67
|
+
};
|
|
68
|
+
const isAgentToolName = (name, exposures) => name === AGENT_STATUS_TOOL_NAME || exposures.some((exposure) => agentToolName(exposure) === name);
|
|
69
|
+
const freshThreadKey = () => `mcp-${crypto.randomUUID()}`;
|
|
70
|
+
const reference = (path) => {
|
|
71
|
+
return { __lunoraRef: path };
|
|
72
|
+
};
|
|
73
|
+
const defaultWait = async (ms) => {
|
|
74
|
+
await new Promise((resolve) => {
|
|
75
|
+
setTimeout(resolve, ms);
|
|
76
|
+
});
|
|
77
|
+
};
|
|
78
|
+
const ok = (value) => {
|
|
79
|
+
return { content: [{ text: JSON.stringify(value, void 0, 2), type: "text" }] };
|
|
80
|
+
};
|
|
81
|
+
const fail = (text) => {
|
|
82
|
+
return { content: [{ text, type: "text" }], isError: true };
|
|
83
|
+
};
|
|
84
|
+
const readString = (input, key) => {
|
|
85
|
+
const value = input[key];
|
|
86
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
87
|
+
};
|
|
88
|
+
const finalAnswer = (messages) => {
|
|
89
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
90
|
+
const row = messages[index];
|
|
91
|
+
const toolCalls = row?.["toolCalls"];
|
|
92
|
+
const pending = Array.isArray(toolCalls) && toolCalls.length > 0;
|
|
93
|
+
if (row?.["role"] === "assistant" && !pending) {
|
|
94
|
+
return typeof row["content"] === "string" ? row["content"] : "";
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return "";
|
|
98
|
+
};
|
|
99
|
+
const readThreadStatus = (thread) => {
|
|
100
|
+
if (thread !== null && typeof thread === "object" && typeof thread.status === "string") {
|
|
101
|
+
return thread.status;
|
|
102
|
+
}
|
|
103
|
+
return "unknown";
|
|
104
|
+
};
|
|
105
|
+
const readTerminal = async (client, threadKey, status, thread) => {
|
|
106
|
+
const messages = await client.query(reference(AGENT_MESSAGES_PATH), { key: threadKey });
|
|
107
|
+
if (status === "error") {
|
|
108
|
+
const error = thread !== null && typeof thread === "object" ? thread.error : void 0;
|
|
109
|
+
return ok({ error: typeof error === "string" ? error : "the agent run failed", status, threadKey });
|
|
110
|
+
}
|
|
111
|
+
return ok({ status, text: finalAnswer(messages), threadKey });
|
|
112
|
+
};
|
|
113
|
+
const callAgentStatus = async (client, input) => {
|
|
114
|
+
const threadKey = readString(input, "threadKey");
|
|
115
|
+
if (threadKey === void 0) {
|
|
116
|
+
return fail('"threadKey" is required and must be a non-empty string');
|
|
117
|
+
}
|
|
118
|
+
const thread = await client.query(reference(AGENT_THREAD_PATH), { key: threadKey });
|
|
119
|
+
const status = readThreadStatus(thread);
|
|
120
|
+
if (TERMINAL_STATUSES.has(status)) {
|
|
121
|
+
return readTerminal(client, threadKey, status, thread);
|
|
122
|
+
}
|
|
123
|
+
return ok({ status: status === "unknown" ? "running" : status, threadKey });
|
|
124
|
+
};
|
|
125
|
+
const callAgentTool = async (client, name, input, options) => {
|
|
126
|
+
try {
|
|
127
|
+
if (options.allowAgents !== true) {
|
|
128
|
+
return fail(`tool "${name}" is disabled: agent tools are off. Enable them with the LUNORA_MCP_ALLOW_AGENTS env var.`);
|
|
129
|
+
}
|
|
130
|
+
if (name === AGENT_STATUS_TOOL_NAME) {
|
|
131
|
+
return await callAgentStatus(client, input);
|
|
132
|
+
}
|
|
133
|
+
const exposure = options.exposures.find((candidate) => agentToolName(candidate) === name);
|
|
134
|
+
if (exposure === void 0) {
|
|
135
|
+
return fail(`agent tool "${name}" is not exposed by this MCP server`);
|
|
136
|
+
}
|
|
137
|
+
const prompt = readString(input, "prompt");
|
|
138
|
+
if (prompt === void 0) {
|
|
139
|
+
return fail('"prompt" is required and must be a non-empty string');
|
|
140
|
+
}
|
|
141
|
+
const threadKey = readString(input, "threadKey") ?? freshThreadKey();
|
|
142
|
+
const title = readString(input, "title");
|
|
143
|
+
const { id } = await client.mutation(reference(AGENT_RUN_PATH), {
|
|
144
|
+
agent: exposure.name,
|
|
145
|
+
input: prompt,
|
|
146
|
+
threadKey,
|
|
147
|
+
...title === void 0 ? {} : { title }
|
|
148
|
+
});
|
|
149
|
+
const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
150
|
+
const maxWaitMs = options.maxWaitMs ?? DEFAULT_MAX_WAIT_MS;
|
|
151
|
+
const wait = options.wait ?? defaultWait;
|
|
152
|
+
const maxPolls = Math.max(1, Math.ceil(maxWaitMs / pollIntervalMs));
|
|
153
|
+
for (let attempt = 0; attempt < maxPolls; attempt += 1) {
|
|
154
|
+
const thread = await client.query(reference(AGENT_THREAD_PATH), { key: threadKey });
|
|
155
|
+
const status = readThreadStatus(thread);
|
|
156
|
+
if (TERMINAL_STATUSES.has(status)) {
|
|
157
|
+
return await readTerminal(client, threadKey, status, thread);
|
|
158
|
+
}
|
|
159
|
+
await wait(pollIntervalMs);
|
|
160
|
+
}
|
|
161
|
+
return ok({ hint: "call lunora_agent_status with this threadKey to poll for the answer", runId: id, status: "running", threadKey });
|
|
162
|
+
} catch (error) {
|
|
163
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
164
|
+
return fail(message);
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
export { AGENT_RUN_INPUT_SCHEMA, AGENT_STATUS_TOOL_NAME, agentToolDefinitions, callAgentTool, finalAnswer, isAgentToolName, parseAgentsEnv };
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { LunoraError } from '@lunora/errors';
|
|
2
|
+
|
|
3
|
+
const RUN_INPUT_SCHEMA = {
|
|
4
|
+
properties: {
|
|
5
|
+
args: { description: "Arguments object passed to the function", type: "object" },
|
|
6
|
+
functionPath: { description: 'Function reference, e.g. "messages:send"', type: "string" },
|
|
7
|
+
shardKey: { description: "Optional shard key when the function is .shardBy()-partitioned", type: "string" }
|
|
8
|
+
},
|
|
9
|
+
required: ["functionPath"],
|
|
10
|
+
type: "object"
|
|
11
|
+
};
|
|
12
|
+
const NO_INPUT_SCHEMA = { properties: {}, type: "object" };
|
|
13
|
+
const FUNCTION_PATH_INPUT_SCHEMA = {
|
|
14
|
+
properties: {
|
|
15
|
+
functionPath: { description: 'Function reference, e.g. "messages:send"', type: "string" }
|
|
16
|
+
},
|
|
17
|
+
required: ["functionPath"],
|
|
18
|
+
type: "object"
|
|
19
|
+
};
|
|
20
|
+
const READ_ONLY_TOOL_DEFINITIONS = [
|
|
21
|
+
{
|
|
22
|
+
description: "List the deployment's public functions (queries, mutations, actions) with their kinds.",
|
|
23
|
+
inputSchema: NO_INPUT_SCHEMA,
|
|
24
|
+
name: "lunora_list_functions"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
description: "List the deployment's .global() tables and their column shapes.",
|
|
28
|
+
inputSchema: NO_INPUT_SCHEMA,
|
|
29
|
+
name: "lunora_list_tables"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
description: "Return a function's argument JSON Schema and kind, so a caller can construct a valid arguments object. Call lunora_list_functions first to discover available function paths.",
|
|
33
|
+
inputSchema: FUNCTION_PATH_INPUT_SCHEMA,
|
|
34
|
+
name: "lunora_get_function_schema"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
description: "Run a query and return its result. Read-only.",
|
|
38
|
+
inputSchema: RUN_INPUT_SCHEMA,
|
|
39
|
+
name: "lunora_run_query"
|
|
40
|
+
}
|
|
41
|
+
];
|
|
42
|
+
const WRITE_TOOL_DEFINITIONS = [
|
|
43
|
+
{
|
|
44
|
+
description: "Run a mutation and return its result. Writes data — use with care.",
|
|
45
|
+
inputSchema: RUN_INPUT_SCHEMA,
|
|
46
|
+
name: "lunora_run_mutation"
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
description: "Run an action and return its result. May call external services.",
|
|
50
|
+
inputSchema: RUN_INPUT_SCHEMA,
|
|
51
|
+
name: "lunora_run_action"
|
|
52
|
+
}
|
|
53
|
+
];
|
|
54
|
+
const WRITE_TOOL_NAMES = new Set(WRITE_TOOL_DEFINITIONS.map((tool) => tool.name));
|
|
55
|
+
const toolDefinitions = (allowWrites) => (
|
|
56
|
+
// Fail closed: only the boolean `true` opts in. These are exported helpers, so
|
|
57
|
+
// an env-plumbed/JS caller could pass a truthy string like `"false"`/`"0"` —
|
|
58
|
+
// the explicit `=== true` guards that despite the declared `boolean` type.
|
|
59
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-boolean-literal-compare -- intentional runtime guard at an exported API boundary against non-boolean callers
|
|
60
|
+
allowWrites === true ? [...READ_ONLY_TOOL_DEFINITIONS, ...WRITE_TOOL_DEFINITIONS] : READ_ONLY_TOOL_DEFINITIONS
|
|
61
|
+
);
|
|
62
|
+
const readFunctionPath = (input) => {
|
|
63
|
+
const { functionPath } = input;
|
|
64
|
+
if (typeof functionPath !== "string" || functionPath.length === 0) {
|
|
65
|
+
throw new LunoraError("INTERNAL", '"functionPath" is required and must be a non-empty string');
|
|
66
|
+
}
|
|
67
|
+
return functionPath;
|
|
68
|
+
};
|
|
69
|
+
const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
70
|
+
const describeArgs = (value) => Array.isArray(value) ? "an array" : `a ${typeof value}`;
|
|
71
|
+
const readArgumentsBag = (raw) => {
|
|
72
|
+
if (raw === void 0 || raw === null) {
|
|
73
|
+
return {};
|
|
74
|
+
}
|
|
75
|
+
if (typeof raw === "string") {
|
|
76
|
+
let parsed;
|
|
77
|
+
try {
|
|
78
|
+
parsed = JSON.parse(raw);
|
|
79
|
+
} catch {
|
|
80
|
+
throw new LunoraError("BAD_REQUEST", `"args" must be a JSON object; received a string that is not valid JSON`);
|
|
81
|
+
}
|
|
82
|
+
if (!isPlainObject(parsed)) {
|
|
83
|
+
throw new LunoraError("BAD_REQUEST", `"args" must be a JSON object; the provided string decoded to ${describeArgs(parsed)}`);
|
|
84
|
+
}
|
|
85
|
+
return parsed;
|
|
86
|
+
}
|
|
87
|
+
if (!isPlainObject(raw)) {
|
|
88
|
+
throw new LunoraError("BAD_REQUEST", `"args" must be a JSON object, got ${describeArgs(raw)}`);
|
|
89
|
+
}
|
|
90
|
+
return raw;
|
|
91
|
+
};
|
|
92
|
+
const readRunArguments = (input) => {
|
|
93
|
+
const functionPath = readFunctionPath(input);
|
|
94
|
+
const args = readArgumentsBag(input.args);
|
|
95
|
+
const shardKey = typeof input.shardKey === "string" && input.shardKey.length > 0 ? input.shardKey : void 0;
|
|
96
|
+
return { args, functionPath, shardKey };
|
|
97
|
+
};
|
|
98
|
+
const reference = (functionPath) => {
|
|
99
|
+
return { __lunoraRef: functionPath };
|
|
100
|
+
};
|
|
101
|
+
const bytesToBase64 = (bytes) => {
|
|
102
|
+
let binary = "";
|
|
103
|
+
const chunk = 32768;
|
|
104
|
+
for (let index = 0; index < bytes.length; index += chunk) {
|
|
105
|
+
binary += String.fromCharCode(...bytes.subarray(index, index + chunk));
|
|
106
|
+
}
|
|
107
|
+
return btoa(binary);
|
|
108
|
+
};
|
|
109
|
+
const jsonResultReplacer = (_key, value) => {
|
|
110
|
+
if (typeof value === "bigint") {
|
|
111
|
+
return value.toString();
|
|
112
|
+
}
|
|
113
|
+
if (value instanceof ArrayBuffer) {
|
|
114
|
+
return bytesToBase64(new Uint8Array(value));
|
|
115
|
+
}
|
|
116
|
+
if (ArrayBuffer.isView(value)) {
|
|
117
|
+
const view = value;
|
|
118
|
+
return bytesToBase64(new Uint8Array(view.buffer, view.byteOffset, view.byteLength));
|
|
119
|
+
}
|
|
120
|
+
return value;
|
|
121
|
+
};
|
|
122
|
+
const ok = (value) => {
|
|
123
|
+
const text = value === void 0 ? "null" : JSON.stringify(value, jsonResultReplacer, 2);
|
|
124
|
+
return { content: [{ text, type: "text" }] };
|
|
125
|
+
};
|
|
126
|
+
const FUNCTIONS_CACHE_TTL_MS = 3e4;
|
|
127
|
+
const functionsCache = /* @__PURE__ */ new WeakMap();
|
|
128
|
+
const listFunctionsCached = (client) => {
|
|
129
|
+
const now = Date.now();
|
|
130
|
+
const cached = functionsCache.get(client);
|
|
131
|
+
if (cached !== void 0 && cached.expiresAt > now) {
|
|
132
|
+
return cached.promise;
|
|
133
|
+
}
|
|
134
|
+
const promise = client.listFunctions().catch((error) => {
|
|
135
|
+
if (functionsCache.get(client)?.promise === promise) {
|
|
136
|
+
functionsCache.delete(client);
|
|
137
|
+
}
|
|
138
|
+
throw error;
|
|
139
|
+
});
|
|
140
|
+
functionsCache.set(client, { expiresAt: now + FUNCTIONS_CACHE_TTL_MS, promise });
|
|
141
|
+
return promise;
|
|
142
|
+
};
|
|
143
|
+
const assertRunnable = async (client, functionPath, expectedKind) => {
|
|
144
|
+
const functions = await listFunctionsCached(client);
|
|
145
|
+
const descriptor = functions.find((function_) => function_.path === functionPath);
|
|
146
|
+
if (descriptor === void 0) {
|
|
147
|
+
throw new LunoraError("NOT_FOUND", `function not found or not public: ${functionPath}`);
|
|
148
|
+
}
|
|
149
|
+
if (descriptor.kind !== expectedKind) {
|
|
150
|
+
throw new LunoraError("BAD_REQUEST", `function ${functionPath} is a ${descriptor.kind}, not a ${expectedKind}`);
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
const callTool = async (client, name, input, allowWrites = false) => {
|
|
154
|
+
try {
|
|
155
|
+
if (allowWrites !== true && WRITE_TOOL_NAMES.has(name)) {
|
|
156
|
+
return {
|
|
157
|
+
content: [
|
|
158
|
+
{ text: `tool "${name}" is disabled: this MCP server is read-only. Enable writes with the LUNORA_MCP_ALLOW_WRITES env var.`, type: "text" }
|
|
159
|
+
],
|
|
160
|
+
isError: true
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
switch (name) {
|
|
164
|
+
case "lunora_get_function_schema": {
|
|
165
|
+
const functionPath = readFunctionPath(input);
|
|
166
|
+
const functions = await listFunctionsCached(client);
|
|
167
|
+
const descriptor = functions.find((function_) => function_.path === functionPath);
|
|
168
|
+
if (descriptor === void 0) {
|
|
169
|
+
return { content: [{ text: `function not found: ${functionPath}`, type: "text" }], isError: true };
|
|
170
|
+
}
|
|
171
|
+
return ok({ args: descriptor.args ?? [], kind: descriptor.kind, path: descriptor.path });
|
|
172
|
+
}
|
|
173
|
+
case "lunora_list_functions": {
|
|
174
|
+
return ok(await listFunctionsCached(client));
|
|
175
|
+
}
|
|
176
|
+
case "lunora_list_tables": {
|
|
177
|
+
return ok(await client.listGlobalTables());
|
|
178
|
+
}
|
|
179
|
+
case "lunora_run_action": {
|
|
180
|
+
const { args, functionPath, shardKey } = readRunArguments(input);
|
|
181
|
+
await assertRunnable(client, functionPath, "action");
|
|
182
|
+
return ok(await client.action(reference(functionPath), args, { shardKey }));
|
|
183
|
+
}
|
|
184
|
+
case "lunora_run_mutation": {
|
|
185
|
+
const { args, functionPath, shardKey } = readRunArguments(input);
|
|
186
|
+
await assertRunnable(client, functionPath, "mutation");
|
|
187
|
+
return ok(await client.mutation(reference(functionPath), args, { shardKey }));
|
|
188
|
+
}
|
|
189
|
+
case "lunora_run_query": {
|
|
190
|
+
const { args, functionPath, shardKey } = readRunArguments(input);
|
|
191
|
+
await assertRunnable(client, functionPath, "query");
|
|
192
|
+
return ok(await client.query(reference(functionPath), args, { shardKey }));
|
|
193
|
+
}
|
|
194
|
+
default: {
|
|
195
|
+
return { content: [{ text: `unknown tool: ${name}`, type: "text" }], isError: true };
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
} catch (error) {
|
|
199
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
200
|
+
return { content: [{ text: message, type: "text" }], isError: true };
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
export { READ_ONLY_TOOL_DEFINITIONS, WRITE_TOOL_DEFINITIONS, callTool, toolDefinitions };
|
|
@@ -2,10 +2,12 @@ import { readFileSync } from 'node:fs';
|
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { LunoraClient } from '@lunora/client';
|
|
5
|
+
import { LunoraError } from '@lunora/errors';
|
|
5
6
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
6
7
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
7
8
|
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
8
|
-
import {
|
|
9
|
+
import { agentToolDefinitions, isAgentToolName, callAgentTool } from './AGENT_RUN_INPUT_SCHEMA-DWobae3P.mjs';
|
|
10
|
+
import { toolDefinitions, callTool } from './READ_ONLY_TOOL_DEFINITIONS-CWb_wFd5.mjs';
|
|
9
11
|
|
|
10
12
|
const resolveVersion = () => {
|
|
11
13
|
try {
|
|
@@ -35,7 +37,7 @@ const resolveClient = (options) => {
|
|
|
35
37
|
return options.client;
|
|
36
38
|
}
|
|
37
39
|
if (options.url === void 0) {
|
|
38
|
-
throw new
|
|
40
|
+
throw new LunoraError("INTERNAL", "createLunoraMcpServer requires either a `client` or a `url`");
|
|
39
41
|
}
|
|
40
42
|
const client = new LunoraClient({ fetch: options.fetch, url: options.url });
|
|
41
43
|
if (options.token !== void 0) {
|
|
@@ -45,12 +47,22 @@ const resolveClient = (options) => {
|
|
|
45
47
|
};
|
|
46
48
|
const createLunoraMcpServer = (options) => {
|
|
47
49
|
const client = resolveClient(options);
|
|
50
|
+
const allowWrites = options.allowWrites ?? false;
|
|
51
|
+
const allowAgents = options.allowAgents ?? false;
|
|
52
|
+
const agents = options.agents ?? [];
|
|
48
53
|
const server = new Server(SERVER_INFO, { capabilities: { tools: {} } });
|
|
49
54
|
server.setRequestHandler(ListToolsRequestSchema, () => {
|
|
50
|
-
return { tools: [...
|
|
55
|
+
return { tools: [...toolDefinitions(allowWrites), ...agentToolDefinitions(agents, allowAgents)] };
|
|
51
56
|
});
|
|
52
57
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
53
|
-
const
|
|
58
|
+
const { arguments: rawArguments, name } = request.params;
|
|
59
|
+
const input = rawArguments ?? {};
|
|
60
|
+
const result = isAgentToolName(name, agents) ? await callAgentTool(client, name, input, {
|
|
61
|
+
allowAgents,
|
|
62
|
+
exposures: agents,
|
|
63
|
+
...options.agentMaxWaitMs === void 0 ? {} : { maxWaitMs: options.agentMaxWaitMs },
|
|
64
|
+
...options.agentPollIntervalMs === void 0 ? {} : { pollIntervalMs: options.agentPollIntervalMs }
|
|
65
|
+
}) : await callTool(client, name, input, allowWrites);
|
|
54
66
|
return result;
|
|
55
67
|
});
|
|
56
68
|
return server;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
|
|
2
|
+
import { createLunoraMcpServer } from './connectStdio-bkJcaXYE.mjs';
|
|
3
|
+
|
|
4
|
+
const serveStateless = async (server, request, options) => {
|
|
5
|
+
const transport = new WebStandardStreamableHTTPServerTransport({ enableJsonResponse: true, sessionIdGenerator: void 0 });
|
|
6
|
+
await server.connect(transport);
|
|
7
|
+
const response = await transport.handleRequest(request, options);
|
|
8
|
+
transport.close().catch(() => void 0);
|
|
9
|
+
server.close().catch(() => void 0);
|
|
10
|
+
return response;
|
|
11
|
+
};
|
|
12
|
+
const createMcpFetchHandler = (options) => (request) => serveStateless(createLunoraMcpServer(options), request);
|
|
13
|
+
|
|
14
|
+
export { createMcpFetchHandler, serveStateless };
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { LunoraError } from '@lunora/errors';
|
|
2
|
+
import { createChargeMiddleware } from '@lunora/x402/charge';
|
|
3
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
4
|
+
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
5
|
+
import { serveStateless } from './createMcpFetchHandler-DfzBSLrm.mjs';
|
|
6
|
+
|
|
7
|
+
const DEFAULT_SERVER_INFO = { name: "lunora-paid-mcp", version: "0.0.0" };
|
|
8
|
+
const CALL_TOOL_METHOD = "tools/call";
|
|
9
|
+
const callToolName = (message) => {
|
|
10
|
+
if (typeof message !== "object" || message === null) {
|
|
11
|
+
return void 0;
|
|
12
|
+
}
|
|
13
|
+
const { method, params } = message;
|
|
14
|
+
if (method !== CALL_TOOL_METHOD || typeof params !== "object" || params === null) {
|
|
15
|
+
return void 0;
|
|
16
|
+
}
|
|
17
|
+
const { name } = params;
|
|
18
|
+
return typeof name === "string" ? name : void 0;
|
|
19
|
+
};
|
|
20
|
+
const refuseBatch = () => Response.json({ error: "A JSON-RPC batch may not reference a paid MCP tool; send paid tools/call requests individually." }, { status: 400 });
|
|
21
|
+
const createPaidMcpServer = (config) => {
|
|
22
|
+
const tools = /* @__PURE__ */ new Map();
|
|
23
|
+
const prices = /* @__PURE__ */ new Map();
|
|
24
|
+
const middlewareByTool = /* @__PURE__ */ new Map();
|
|
25
|
+
const serverInfo = config.serverInfo ?? DEFAULT_SERVER_INFO;
|
|
26
|
+
const register = (options, handler, price) => {
|
|
27
|
+
if (tools.has(options.name)) {
|
|
28
|
+
throw new LunoraError("BAD_REQUEST", `MCP tool "${options.name}" is already registered.`);
|
|
29
|
+
}
|
|
30
|
+
const definition = { description: options.description, inputSchema: options.inputSchema, name: options.name };
|
|
31
|
+
if (options.annotations !== void 0) {
|
|
32
|
+
definition.annotations = options.annotations;
|
|
33
|
+
}
|
|
34
|
+
tools.set(options.name, { definition, handler });
|
|
35
|
+
if (price !== void 0) {
|
|
36
|
+
prices.set(options.name, price);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
const buildServer = () => {
|
|
40
|
+
const server = new Server(serverInfo, { capabilities: { tools: {} } });
|
|
41
|
+
server.setRequestHandler(ListToolsRequestSchema, () => {
|
|
42
|
+
return { tools: [...tools.values()].map((entry) => entry.definition) };
|
|
43
|
+
});
|
|
44
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
45
|
+
const entry = tools.get(request.params.name);
|
|
46
|
+
if (entry === void 0) {
|
|
47
|
+
return { content: [{ text: `unknown tool: ${request.params.name}`, type: "text" }], isError: true };
|
|
48
|
+
}
|
|
49
|
+
const result = await entry.handler(request.params.arguments ?? {});
|
|
50
|
+
return result;
|
|
51
|
+
});
|
|
52
|
+
return server;
|
|
53
|
+
};
|
|
54
|
+
const gateFor = (name, price) => {
|
|
55
|
+
let pending = middlewareByTool.get(name);
|
|
56
|
+
if (pending === void 0) {
|
|
57
|
+
pending = createChargeMiddleware({ ...config.charge, price }, { resource: name }).catch((error) => {
|
|
58
|
+
middlewareByTool.delete(name);
|
|
59
|
+
throw error;
|
|
60
|
+
});
|
|
61
|
+
middlewareByTool.set(name, pending);
|
|
62
|
+
}
|
|
63
|
+
return pending;
|
|
64
|
+
};
|
|
65
|
+
const fetchHandler = async (request) => {
|
|
66
|
+
let parsedBody;
|
|
67
|
+
try {
|
|
68
|
+
parsedBody = await request.clone().json();
|
|
69
|
+
} catch {
|
|
70
|
+
parsedBody = void 0;
|
|
71
|
+
}
|
|
72
|
+
const dispatch = () => serveStateless(buildServer(), request, parsedBody === void 0 ? void 0 : { parsedBody });
|
|
73
|
+
if (Array.isArray(parsedBody)) {
|
|
74
|
+
return parsedBody.some((message) => prices.has(callToolName(message) ?? "")) ? refuseBatch() : dispatch();
|
|
75
|
+
}
|
|
76
|
+
const name = callToolName(parsedBody);
|
|
77
|
+
const price = name === void 0 ? void 0 : prices.get(name);
|
|
78
|
+
if (name === void 0 || price === void 0) {
|
|
79
|
+
return dispatch();
|
|
80
|
+
}
|
|
81
|
+
const middleware = await gateFor(name, price);
|
|
82
|
+
return middleware.handle(request, dispatch);
|
|
83
|
+
};
|
|
84
|
+
const paidTool = (options, handler) => {
|
|
85
|
+
register(options, handler, options.price);
|
|
86
|
+
};
|
|
87
|
+
const tool = (options, handler) => {
|
|
88
|
+
register(options, handler);
|
|
89
|
+
};
|
|
90
|
+
return { fetchHandler, paidTool, tool };
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export { createPaidMcpServer };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lunora/mcp",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.30",
|
|
4
4
|
"description": "Model Context Protocol server exposing a Lunora deployment to AI agents",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agents",
|
|
@@ -49,7 +49,9 @@
|
|
|
49
49
|
"access": "public"
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
|
-
"@lunora/client": "1.0.0-alpha.
|
|
52
|
+
"@lunora/client": "1.0.0-alpha.28",
|
|
53
|
+
"@lunora/errors": "1.0.0-alpha.8",
|
|
54
|
+
"@lunora/x402": "1.0.0-alpha.6",
|
|
53
55
|
"@modelcontextprotocol/sdk": "^1.29.0"
|
|
54
56
|
},
|
|
55
57
|
"engines": {
|
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
const RUN_INPUT_SCHEMA = {
|
|
2
|
-
properties: {
|
|
3
|
-
args: { description: "Arguments object passed to the function", type: "object" },
|
|
4
|
-
functionPath: { description: 'Function reference, e.g. "messages:send"', type: "string" },
|
|
5
|
-
shardKey: { description: "Optional shard key when the function is .shardBy()-partitioned", type: "string" }
|
|
6
|
-
},
|
|
7
|
-
required: ["functionPath"],
|
|
8
|
-
type: "object"
|
|
9
|
-
};
|
|
10
|
-
const NO_INPUT_SCHEMA = { properties: {}, type: "object" };
|
|
11
|
-
const FUNCTION_PATH_INPUT_SCHEMA = {
|
|
12
|
-
properties: {
|
|
13
|
-
functionPath: { description: 'Function reference, e.g. "messages:send"', type: "string" }
|
|
14
|
-
},
|
|
15
|
-
required: ["functionPath"],
|
|
16
|
-
type: "object"
|
|
17
|
-
};
|
|
18
|
-
const TOOL_DEFINITIONS = [
|
|
19
|
-
{
|
|
20
|
-
description: "List the deployment's public functions (queries, mutations, actions) with their kinds.",
|
|
21
|
-
inputSchema: NO_INPUT_SCHEMA,
|
|
22
|
-
name: "lunora_list_functions"
|
|
23
|
-
},
|
|
24
|
-
{
|
|
25
|
-
description: "List the deployment's .global() tables and their column shapes.",
|
|
26
|
-
inputSchema: NO_INPUT_SCHEMA,
|
|
27
|
-
name: "lunora_list_tables"
|
|
28
|
-
},
|
|
29
|
-
{
|
|
30
|
-
description: "Return a function's argument JSON Schema and kind, so a caller can construct a valid arguments object. Call lunora_list_functions first to discover available function paths.",
|
|
31
|
-
inputSchema: FUNCTION_PATH_INPUT_SCHEMA,
|
|
32
|
-
name: "lunora_get_function_schema"
|
|
33
|
-
},
|
|
34
|
-
{
|
|
35
|
-
description: "Run a query and return its result. Read-only.",
|
|
36
|
-
inputSchema: RUN_INPUT_SCHEMA,
|
|
37
|
-
name: "lunora_run_query"
|
|
38
|
-
},
|
|
39
|
-
{
|
|
40
|
-
description: "Run a mutation and return its result. Writes data — use with care.",
|
|
41
|
-
inputSchema: RUN_INPUT_SCHEMA,
|
|
42
|
-
name: "lunora_run_mutation"
|
|
43
|
-
},
|
|
44
|
-
{
|
|
45
|
-
description: "Run an action and return its result. May call external services.",
|
|
46
|
-
inputSchema: RUN_INPUT_SCHEMA,
|
|
47
|
-
name: "lunora_run_action"
|
|
48
|
-
}
|
|
49
|
-
];
|
|
50
|
-
const readFunctionPath = (input) => {
|
|
51
|
-
const { functionPath } = input;
|
|
52
|
-
if (typeof functionPath !== "string" || functionPath.length === 0) {
|
|
53
|
-
throw new Error('"functionPath" is required and must be a non-empty string');
|
|
54
|
-
}
|
|
55
|
-
return functionPath;
|
|
56
|
-
};
|
|
57
|
-
const readRunArguments = (input) => {
|
|
58
|
-
const functionPath = readFunctionPath(input);
|
|
59
|
-
const rawArguments = input.args;
|
|
60
|
-
const isPlainObject = typeof rawArguments === "object" && rawArguments !== null && !Array.isArray(rawArguments);
|
|
61
|
-
const args = isPlainObject ? rawArguments : {};
|
|
62
|
-
const shardKey = typeof input.shardKey === "string" && input.shardKey.length > 0 ? input.shardKey : void 0;
|
|
63
|
-
return { args, functionPath, shardKey };
|
|
64
|
-
};
|
|
65
|
-
const reference = (functionPath) => {
|
|
66
|
-
return { __lunoraRef: functionPath };
|
|
67
|
-
};
|
|
68
|
-
const ok = (value) => {
|
|
69
|
-
const text = value === void 0 ? "null" : JSON.stringify(value, void 0, 2);
|
|
70
|
-
return { content: [{ text, type: "text" }] };
|
|
71
|
-
};
|
|
72
|
-
const callTool = async (client, name, input) => {
|
|
73
|
-
try {
|
|
74
|
-
switch (name) {
|
|
75
|
-
case "lunora_get_function_schema": {
|
|
76
|
-
const functionPath = readFunctionPath(input);
|
|
77
|
-
const functions = await client.listFunctions();
|
|
78
|
-
const descriptor = functions.find((function_) => function_.path === functionPath);
|
|
79
|
-
if (descriptor === void 0) {
|
|
80
|
-
return { content: [{ text: `function not found: ${functionPath}`, type: "text" }], isError: true };
|
|
81
|
-
}
|
|
82
|
-
return ok({ args: descriptor.args ?? [], kind: descriptor.kind, path: descriptor.path });
|
|
83
|
-
}
|
|
84
|
-
case "lunora_list_functions": {
|
|
85
|
-
return ok(await client.listFunctions());
|
|
86
|
-
}
|
|
87
|
-
case "lunora_list_tables": {
|
|
88
|
-
return ok(await client.listGlobalTables());
|
|
89
|
-
}
|
|
90
|
-
case "lunora_run_action": {
|
|
91
|
-
const { args, functionPath, shardKey } = readRunArguments(input);
|
|
92
|
-
return ok(await client.action(reference(functionPath), args, { shardKey }));
|
|
93
|
-
}
|
|
94
|
-
case "lunora_run_mutation": {
|
|
95
|
-
const { args, functionPath, shardKey } = readRunArguments(input);
|
|
96
|
-
return ok(await client.mutation(reference(functionPath), args, { shardKey }));
|
|
97
|
-
}
|
|
98
|
-
case "lunora_run_query": {
|
|
99
|
-
const { args, functionPath, shardKey } = readRunArguments(input);
|
|
100
|
-
return ok(await client.query(reference(functionPath), args, { shardKey }));
|
|
101
|
-
}
|
|
102
|
-
default: {
|
|
103
|
-
return { content: [{ text: `unknown tool: ${name}`, type: "text" }], isError: true };
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
} catch (error) {
|
|
107
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
108
|
-
return { content: [{ text: message, type: "text" }], isError: true };
|
|
109
|
-
}
|
|
110
|
-
};
|
|
111
|
-
|
|
112
|
-
export { TOOL_DEFINITIONS, callTool };
|