diff --git a/CLAUDE.md b/CLAUDE.md index ee525e37..df2c533f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -703,7 +703,9 @@ how to write apps and to give them a good starting point. () and use its **high-level APIs wherever possible** rather than inventing their own functionality. - **When you add functionality to the platform, add it to the demos as well if - it makes sense**, so agents learn it by reading them. Every demo has the three + it makes sense**, so agents learn it by reading them. `demo-crud` is where a + lambda offers tools to AI agents (`Tools.cs`, over the same store as its API) + and describes its API in the words of the documentation attributes. Every demo has the three pages of its documentation and tests and a script its tests run (`EveryDemoSaysWhatItIsWhyAndHowItIsTested`); a new demo gets them too. They keep their records in the database, migrated with Evolve and read and @@ -902,6 +904,9 @@ change; the helpers are in `OperationLog`. - API reads are not logged (the editor polls), apart from exports and downloads; an agent's reads through MCP are. - A refused request is not logged here - its request line has the status. +- **The MCP module's own lines are filtered below warnings** (`Program`): it + writes one per tool call beside the tool's own, and one per refusal. A tool + that fails is an error, and is written down. ### Frameworks: GenHTTP and Ioxide @@ -909,6 +914,21 @@ change; the helpers are in `OperationLog`. clearly** - say what it is, where, and a reproduction - **instead of working around it**, so it can be fixed at the source. Tell the owner in your answer; do not bury it in a comment. +- **Use what the framework offers the way it is meant to be used - decided by + the owner** ("embrace it"). When GenHTTP brings a module for something the + platform does by hand, move onto it rather than keep a protocol of our own + beside it; what it lacks is a bug or a feature to ask for at the source. +- **The MCP endpoint is GenHTTP's MCP module.** `/mcp` is `McpEndpoint` over + `GenHTTP.Modules.Mcp`, the tools are the methods of `McpTools` marked + `[Tool]` - their `[Summary]` the description, their typed parameters the + arguments - and a fixed limit of an argument is an attribute on it (`[Range]`, + `[MaxLength]`, `[AllowedValues]`), advertised and checked by the module, not + a check of our own. The module takes every exception but its sealed + `ProviderException` for a fault, so a refusal of the platform + (`LambdaException`) is turned into an error that says why in a sentence + (`Refusing`), never left to be logged as a crash. Its protocol - versions, + notifications, origins - is the framework's and is not tested here; what we + configure (`LAMBDA_MCP_ORIGINS`, the instructions) is. - **Do not write tests for the frameworks.** Test GenHTTP Lambda only. - The framework source is normally checked out beside this repository (`../GenHTTP`). Read it instead of decompiling the NuGet packages. @@ -1044,7 +1064,7 @@ touches all that apply: The same rule is worded the same way in all of them. -The MCP instructions (`McpHandler`) are **short**: how to get from nothing to +The MCP instructions (`McpEndpoint`) are **short**: how to get from nothing to something online, how a lambda that exists is changed, and the rules an agent would otherwise break before it reads the guide (where data goes, the database, waiting for tasks, relative paths, secrets, documentation, pages @@ -1077,7 +1097,13 @@ GenHTTP's documentation attributes beside the code they describe. key, a demo's refusal) is a constant of `ApiDocumentation`. - **Limits the operator sets are never written as numbers**: say where to read them (`GET /system`, the summary's `limits`). Fixed ones (100 secrets, - 32 KB a value) may be. + 32 KB a value) may be. The same holds for the MCP tools, whose descriptions + are fixed when the endpoint starts: a limit of the panel is named where to + read it (`platform_guide`), never written into one. +- **Authentication describes itself.** The admin token's concern declares the + `adminToken` security scheme and the 401 and 403 it answers; an operation + behind it says nothing about the token of its own. Everywhere else the + editor key in the path is the credential, and no operation names a scheme. - `ApiDocumentationTests` refuses an operation without a summary, a parameter, answer, model or field without a description, a call about a lambda that does not say how an unknown key is answered, and a tag in no group. A new diff --git a/Directory.Packages.props b/Directory.Packages.props index 11d8b435..f31d3cfc 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,11 +7,11 @@ - - + + - + diff --git a/README.md b/README.md index 2c01ec64..5bacb72f 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,14 @@ connection, which is why a socket may stay open far longer than a lambda is given to answer. `FrameType`, which the imperative flavour reads off every frame, is one of the names a lambda is given, so no import is needed. +A lambda may offer tools to AI agents itself, over the Model Context Protocol: +`Tools.Create()` with a class whose methods are marked `[Tool]`, added at a +path such as `mcp`, which an agent then connects to. And a webservice of its +own describes itself as OpenAPI, in the words of `[Summary]`, `[Remarks]`, +`[Tag]` and `[Response]` on its routes and records. The MCP module and those +attributes are imported for every lambda and in its export; `demo-crud` shows +both, and the guide says how (`toolsForAgents`, `describingAnApi`). + The editor says when both free tier timers run out: a hint under the public URL for the deployment, and a chip beside the buttons for the lambda itself, which opens an explanation of how each one is extended. @@ -1361,7 +1369,7 @@ server's configuration and are set in the administration panel while it runs | `LAMBDA_LOG_GEO_REFRESH_HOURS` | `168` | how often the registry files are refetched | | `LAMBDA_LOG_GEO_PLACES` | `true` | also fetch the town and network databases | | `LAMBDA_LOG_MAX_LINES_PER_REQUEST` | `200` | before one request's output is cut off | -| `LAMBDA_MCP_ORIGINS` | - | hosts a browser may use `/mcp` from | +| `LAMBDA_MCP_ORIGINS` | - | hosts (or origins) a browser may use `/mcp` from | | `LAMBDA_SECRETS_KEY` | `secrets.key` | the installation's half of the key secrets are sealed with, see [Secrets](#secrets) | | `LAMBDA_PUBLIC_URL` | - | canonical address, enables the sitemap | | `LAMBDA_HOSTING_URL` | `http://localhost:{LAMBDA_PORT}` | the domain the lambdas answer below, a subdomain each: `https://genhttp.run` | @@ -1699,8 +1707,21 @@ enters it. There is a Model Context Protocol endpoint at `/mcp`, so an agent can build something here and put it online without a person driving the editor. It is -JSON-RPC over a single path, taking POST; a GET is declined with 405, because -this server answers rather than streams. +GenHTTP's MCP module (`GenHTTP.Modules.Mcp`): JSON-RPC over a single path, +taking POST, in the stateless revision of the protocol and in the ones before +it that start with a handshake, which it answers without keeping a session. A +GET is declined with 405, because this server answers rather than streams. + +The tools are the methods of `McpTools` marked `[Tool]`: their `[Summary]` is +what an agent reads, their parameters are the arguments and their schema, and +a fixed limit - a length, a range, the values an argument takes - is an +attribute on the parameter, advertised and checked before the tool runs. A +tool answers with an object, as text for the model and as structured content +for a program. When the platform refuses - a key nobody has, a demo that +cannot be changed, a name that is taken - the answer is an error that says +why in a sentence, as is one for an argument that does not fit; a failed +deployment or merge is an error that carries its diagnostics as structured +content. ``` https://genhttp.dev/mcp @@ -1879,10 +1900,14 @@ Nothing is created until `acceptTerms` is true, and the editor key that comes back is the only way into what was made. There is no session and nothing is remembered between calls: everything a call needs is in its arguments. -`LAMBDA_MCP_ORIGINS` lists the hosts a browser may drive the endpoint from. An -agent speaking HTTP sends no `Origin` and is never checked against it; the list -is there because a browser does, and a page on another site could otherwise aim -somebody's browser at this endpoint. +`LAMBDA_MCP_ORIGINS` lists the hosts a browser may drive the endpoint from: a +host stands for its page over HTTPS (`genhttp.dev` for +`https://genhttp.dev`), and an entry with a scheme for the origin it names, +port included (`http://localhost:5173`); an entry that names neither is left +out, with a warning on startup. An agent speaking HTTP sends no +`Origin` and is never checked against it; the list is there because a browser +does, and a page on another site could otherwise aim somebody's browser at +this endpoint. ## Administration @@ -1899,6 +1924,8 @@ the credential and it only ever reaches one lambda; this reads code that belongs to other people and can take their work away, so it asks for `LAMBDA_ADMIN_TOKEN` in an `X-Admin-Token` header (GenHTTP's API key authentication). A request without the header is answered with 401, one with the wrong token with 403. +The concern says so in the API description itself: every operation behind it +requires the `adminToken` security scheme, which Scalar asks for once. Until that variable is set there is no panel at all: `/admin`, `/telemetry` and `/logs` are not there, and answer 404. diff --git a/docker/agent/builder.mjs b/docker/agent/builder.mjs index 8a72b1da..b5544b69 100644 --- a/docker/agent/builder.mjs +++ b/docker/agent/builder.mjs @@ -757,9 +757,15 @@ async function run(job) { if (event.type === 'assistant') { for (const part of event.message?.content ?? []) { if (part.type === 'tool_use') { - const tool = String(part.name ?? '').replace('mcp__genhttp__', ''); + const name = String(part.name ?? ''); + const tool = name.replace('mcp__genhttp__', ''); - calls.set(part.id, { tool, step: step(job, begin(tool, part.input)) }); + // only a tool the run may call reaches the platform, so only its + // errors are the platform's refusals; the CLI turns away any other + // in words of its own, which are no step's problem + const allowed = (job.kind === 'change' ? CHANGE_ALLOW : ALLOW).includes(name); + + calls.set(part.id, { tool, allowed, step: step(job, begin(tool, part.input)) }); closing = ''; } @@ -783,7 +789,7 @@ async function run(job) { const call = calls.get(part.tool_use_id); const from = call?.tool; - const body = parse(part.content); + const body = parse(part.content) ?? (call?.allowed && part.is_error ? refused(part.content) : null); if (call) finish(call.step, from, body); @@ -1088,6 +1094,19 @@ function parse(content) { return null; } +/** + * A refusal of the platform's - a key nobody has, a name that is taken, an + * argument that does not fit - which says why in a sentence rather than in an + * object, as the answer of a step that went wrong. + */ +function refused(content) { + const text = (Array.isArray(content) ? content.filter(c => c.type === 'text').map(c => c.text) : [String(content ?? '')]) + .join(' ') + .trim(); + + return text ? { ok: false, problem: text } : null; +} + /* * Whether a tool's answer says a version went online. online: true says it, * in the answers of a deploy and of a save or a merge with deploy: true - diff --git a/src/GenHTTP.Lambda/Api/AdminResource.cs b/src/GenHTTP.Lambda/Api/AdminResource.cs index 10d99dea..294d413c 100644 --- a/src/GenHTTP.Lambda/Api/AdminResource.cs +++ b/src/GenHTTP.Lambda/Api/AdminResource.cs @@ -1,4 +1,4 @@ -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; using GenHTTP.Api.Protocol; using GenHTTP.Lambda.Api.Infrastructure; @@ -40,9 +40,6 @@ namespace GenHTTP.Lambda.Api; operator's. The answers carry editor keys. The settings and the limits apply to the whole installation, from the next request on. """)] -[RequestHeader(AdminAuthentication.Header, AdminAuthentication.HeaderDescription, Required = true)] -[Response(ResponseStatus.Unauthorized, AdminAuthentication.MissingDescription)] -[Response(ResponseStatus.Forbidden, AdminAuthentication.WrongDescription)] public sealed class AdminResource(IMetaService meta, ITelemetryService telemetry, ISettingsService settings, ILimitsService limits, ILogger logger) { diff --git a/src/GenHTTP.Lambda/Api/BuildResource.cs b/src/GenHTTP.Lambda/Api/BuildResource.cs index 2e9d037b..63514b1c 100644 --- a/src/GenHTTP.Lambda/Api/BuildResource.cs +++ b/src/GenHTTP.Lambda/Api/BuildResource.cs @@ -1,4 +1,4 @@ -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; using GenHTTP.Api.Protocol; using GenHTTP.Lambda.Api.Infrastructure; diff --git a/src/GenHTTP.Lambda/Api/CodeResource.cs b/src/GenHTTP.Lambda/Api/CodeResource.cs index 9b4df7ca..2534cabb 100644 --- a/src/GenHTTP.Lambda/Api/CodeResource.cs +++ b/src/GenHTTP.Lambda/Api/CodeResource.cs @@ -1,4 +1,4 @@ -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; using GenHTTP.Api.Protocol; using GenHTTP.Lambda.Api.Infrastructure; diff --git a/src/GenHTTP.Lambda/Api/DataResource.cs b/src/GenHTTP.Lambda/Api/DataResource.cs index 33248c67..46b36704 100644 --- a/src/GenHTTP.Lambda/Api/DataResource.cs +++ b/src/GenHTTP.Lambda/Api/DataResource.cs @@ -1,4 +1,4 @@ -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; using GenHTTP.Api.Protocol; using GenHTTP.Lambda.Api.Model; diff --git a/src/GenHTTP.Lambda/Api/DatabaseResource.cs b/src/GenHTTP.Lambda/Api/DatabaseResource.cs index c97f85e5..d6955e3e 100644 --- a/src/GenHTTP.Lambda/Api/DatabaseResource.cs +++ b/src/GenHTTP.Lambda/Api/DatabaseResource.cs @@ -1,4 +1,4 @@ -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; using GenHTTP.Api.Protocol; using GenHTTP.Lambda.Api.Infrastructure; diff --git a/src/GenHTTP.Lambda/Api/DemoResource.cs b/src/GenHTTP.Lambda/Api/DemoResource.cs index eae3bd51..f7329435 100644 --- a/src/GenHTTP.Lambda/Api/DemoResource.cs +++ b/src/GenHTTP.Lambda/Api/DemoResource.cs @@ -1,4 +1,4 @@ -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; using GenHTTP.Lambda.Api.Infrastructure; using GenHTTP.Lambda.Api.Model; diff --git a/src/GenHTTP.Lambda/Api/DeploymentResource.cs b/src/GenHTTP.Lambda/Api/DeploymentResource.cs index c9c389c6..10df4bea 100644 --- a/src/GenHTTP.Lambda/Api/DeploymentResource.cs +++ b/src/GenHTTP.Lambda/Api/DeploymentResource.cs @@ -1,4 +1,4 @@ -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; using GenHTTP.Api.Protocol; using GenHTTP.Lambda.Api.Infrastructure; diff --git a/src/GenHTTP.Lambda/Api/FeatureResource.cs b/src/GenHTTP.Lambda/Api/FeatureResource.cs index b6cf71c6..27c21947 100644 --- a/src/GenHTTP.Lambda/Api/FeatureResource.cs +++ b/src/GenHTTP.Lambda/Api/FeatureResource.cs @@ -1,4 +1,4 @@ -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; using GenHTTP.Api.Protocol; using GenHTTP.Lambda.Api.Infrastructure; diff --git a/src/GenHTTP.Lambda/Api/FeatureWorkspaceResource.cs b/src/GenHTTP.Lambda/Api/FeatureWorkspaceResource.cs index b79c32f9..9ddb78c9 100644 --- a/src/GenHTTP.Lambda/Api/FeatureWorkspaceResource.cs +++ b/src/GenHTTP.Lambda/Api/FeatureWorkspaceResource.cs @@ -1,4 +1,4 @@ -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; using GenHTTP.Api.Protocol; using GenHTTP.Lambda.Api.Infrastructure; diff --git a/src/GenHTTP.Lambda/Api/FileResource.cs b/src/GenHTTP.Lambda/Api/FileResource.cs index 9d774907..14c4ac14 100644 --- a/src/GenHTTP.Lambda/Api/FileResource.cs +++ b/src/GenHTTP.Lambda/Api/FileResource.cs @@ -1,4 +1,4 @@ -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; using GenHTTP.Api.Protocol; using GenHTTP.Lambda.Api.Infrastructure; diff --git a/src/GenHTTP.Lambda/Api/Infrastructure/AdminAuthentication.cs b/src/GenHTTP.Lambda/Api/Infrastructure/AdminAuthentication.cs index 2169efa3..e5bcce2c 100644 --- a/src/GenHTTP.Lambda/Api/Infrastructure/AdminAuthentication.cs +++ b/src/GenHTTP.Lambda/Api/Infrastructure/AdminAuthentication.cs @@ -3,6 +3,7 @@ using GenHTTP.Api.Content; using GenHTTP.Api.Content.Authentication; +using GenHTTP.Api.Content.Documentation.Security; using GenHTTP.Lambda.Configuration; @@ -37,14 +38,17 @@ public static class AdminAuthentication /// public const string Header = "X-Admin-Token"; - // how the API description says it, for each resource behind the token - - // the framework's API key concern does not describe itself - - public const string HeaderDescription = "The administration token of the installation (`LAMBDA_ADMIN_TOKEN`)"; - - public const string MissingDescription = "The token is missing."; - - public const string WrongDescription = "The token is wrong."; + /// + /// How the API description names the token. + /// + /// + /// The concern describes itself to the API description: every operation + /// behind it requires this scheme, and says how a request without the + /// token and one with the wrong token are answered. Named for what it is, + /// since Scalar asks for it once by that name for all of them. + /// + public static readonly SecurityScheme Scheme = SecurityScheme.ApiKey(Header, ApiKeyLocation.Header, "The administration token of the installation (`LAMBDA_ADMIN_TOKEN`).") + with { Name = "adminToken" }; /// /// The concern that lets a request with the configured token through. @@ -55,6 +59,7 @@ public static IConcernBuilder Create(LambdaOptions options) return ApiKeyAuthentication.Create() .WithHeader(Header) + .Documentation(Scheme) .Authenticator((_, presented) => { var actual = Encoding.UTF8.GetBytes(presented); diff --git a/src/GenHTTP.Lambda/Api/KeyResource.cs b/src/GenHTTP.Lambda/Api/KeyResource.cs index d5ae98f4..e8e39c53 100644 --- a/src/GenHTTP.Lambda/Api/KeyResource.cs +++ b/src/GenHTTP.Lambda/Api/KeyResource.cs @@ -1,4 +1,4 @@ -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; using GenHTTP.Lambda.Api.Infrastructure; using GenHTTP.Lambda.Api.Model; diff --git a/src/GenHTTP.Lambda/Api/LambdaAgentResource.cs b/src/GenHTTP.Lambda/Api/LambdaAgentResource.cs index 114743b2..f640fdf0 100644 --- a/src/GenHTTP.Lambda/Api/LambdaAgentResource.cs +++ b/src/GenHTTP.Lambda/Api/LambdaAgentResource.cs @@ -1,4 +1,4 @@ -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; using GenHTTP.Api.Protocol; using GenHTTP.Lambda.Api.Infrastructure; diff --git a/src/GenHTTP.Lambda/Api/LambdaAnalyticsResource.cs b/src/GenHTTP.Lambda/Api/LambdaAnalyticsResource.cs index 3181082a..ac4ff989 100644 --- a/src/GenHTTP.Lambda/Api/LambdaAnalyticsResource.cs +++ b/src/GenHTTP.Lambda/Api/LambdaAnalyticsResource.cs @@ -1,4 +1,4 @@ -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; using GenHTTP.Api.Protocol; using GenHTTP.Lambda.Api.Infrastructure; diff --git a/src/GenHTTP.Lambda/Api/LambdaDomainResource.cs b/src/GenHTTP.Lambda/Api/LambdaDomainResource.cs index 75525a28..2db0c3f6 100644 --- a/src/GenHTTP.Lambda/Api/LambdaDomainResource.cs +++ b/src/GenHTTP.Lambda/Api/LambdaDomainResource.cs @@ -1,7 +1,7 @@ using System.Net; using System.Net.Sockets; -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; using GenHTTP.Api.Protocol; using GenHTTP.Lambda.Api.Infrastructure; diff --git a/src/GenHTTP.Lambda/Api/LambdaResource.cs b/src/GenHTTP.Lambda/Api/LambdaResource.cs index d0d8f872..b9e7cda9 100644 --- a/src/GenHTTP.Lambda/Api/LambdaResource.cs +++ b/src/GenHTTP.Lambda/Api/LambdaResource.cs @@ -1,4 +1,4 @@ -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; using GenHTTP.Api.Infrastructure; using GenHTTP.Api.Protocol; diff --git a/src/GenHTTP.Lambda/Api/LambdaShowcaseResource.cs b/src/GenHTTP.Lambda/Api/LambdaShowcaseResource.cs index c96b68a6..ff12e06e 100644 --- a/src/GenHTTP.Lambda/Api/LambdaShowcaseResource.cs +++ b/src/GenHTTP.Lambda/Api/LambdaShowcaseResource.cs @@ -1,4 +1,4 @@ -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; using GenHTTP.Api.Protocol; using GenHTTP.Lambda.Api.Model; diff --git a/src/GenHTTP.Lambda/Api/LambdaSourceResource.cs b/src/GenHTTP.Lambda/Api/LambdaSourceResource.cs index c5992821..c07c5f08 100644 --- a/src/GenHTTP.Lambda/Api/LambdaSourceResource.cs +++ b/src/GenHTTP.Lambda/Api/LambdaSourceResource.cs @@ -1,4 +1,4 @@ -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; using GenHTTP.Api.Protocol; using GenHTTP.Lambda.Api.Model; diff --git a/src/GenHTTP.Lambda/Api/LogResource.cs b/src/GenHTTP.Lambda/Api/LogResource.cs index 3918f8a3..63bf8c3a 100644 --- a/src/GenHTTP.Lambda/Api/LogResource.cs +++ b/src/GenHTTP.Lambda/Api/LogResource.cs @@ -1,5 +1,4 @@ -using GenHTTP.Api.Content.Services; -using GenHTTP.Api.Protocol; +using GenHTTP.Api.Content.Documentation; using GenHTTP.Lambda.Api.Infrastructure; using GenHTTP.Lambda.Api.Model; @@ -26,9 +25,6 @@ namespace GenHTTP.Lambda.Api; and what the server said. Behind the token, since it holds whatever a stranger's code decided to print - and, where the operator records them, the addresses of callers. """)] -[RequestHeader(AdminAuthentication.Header, AdminAuthentication.HeaderDescription, Required = true)] -[Response(ResponseStatus.Unauthorized, AdminAuthentication.MissingDescription)] -[Response(ResponseStatus.Forbidden, AdminAuthentication.WrongDescription)] public sealed class LogResource(ILogBook book, IRunLog runs, LambdaOptions options) { diff --git a/src/GenHTTP.Lambda/Api/Mcp/McpEndpoint.cs b/src/GenHTTP.Lambda/Api/Mcp/McpEndpoint.cs new file mode 100644 index 00000000..d081ad74 --- /dev/null +++ b/src/GenHTTP.Lambda/Api/Mcp/McpEndpoint.cs @@ -0,0 +1,151 @@ +using GenHTTP.Api.Content; + +using GenHTTP.Lambda.Configuration; + +using GenHTTP.Modules.DependencyInjection; + +namespace GenHTTP.Lambda.Api.Mcp; + +/// +/// The Model Context Protocol endpoint, so an agent can build something here +/// and put it online without a person driving the editor. +/// +/// +/// GenHTTP's MCP module speaks the protocol: one path taking POST, answered +/// with one JSON object per request and never with an event stream, a +/// notification acknowledged with 202, the stateless revision of the protocol +/// and the ones before it that start with a handshake - which it answers +/// without keeping a session. That suits this server: everything a call needs +/// is in its arguments, chiefly the editor key, so there is nothing worth +/// keeping between two of them and nothing to lose when a process restarts. +/// +/// The tools are , taken from the container per call. +/// +public static class McpEndpoint +{ + + /// + /// The endpoint, to be added to the platform's layout. + /// + public static IHandlerBuilder Create(LambdaOptions options) + { + var tools = DependentTools.Create() + .Server("genhttp-lambda", "1.0.0", "GenHTTP Lambda") + .Instructions(Instructions) + .Add(); + + foreach (var origin in Origins(options)) + { + tools.AllowOrigin(origin); + } + + return tools; + } + + /// + /// The web pages a browser may drive the endpoint from. + /// + /// + /// Only browsers send an Origin, and only a browser can be made to send a + /// request somebody else wrote: the module refuses every origin it was not + /// given, and an agent speaking HTTP sends none. + /// + internal static IEnumerable Origins(LambdaOptions options) => options.McpOrigins.Select(Origin).OfType(); + + /// + /// The origin an entry of LAMBDA_MCP_ORIGINS stands for, or nothing + /// for one that names none. + /// + /// + /// The variable has always named hosts, so a host stands for its page over + /// HTTPS - the only way the platform serves one - and an entry with a + /// scheme is taken as the origin it names, a port included. The module + /// refuses to start with an origin it cannot read, so one that is none - a + /// wildcard, say, which was never matched either - is left out instead, + /// with a warning on startup. + /// + internal static string? Origin(string entry) + { + var origin = IsOrigin(entry) ? entry : $"https://{entry}"; + + return IsOrigin(origin) ? origin : null; + } + + private static bool IsOrigin(string candidate) + => Uri.TryCreate(candidate, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttps || uri.Scheme == Uri.UriSchemeHttp); + + /// + /// What an agent is told before it calls anything. + /// + /// + /// Short: how to get from nothing to something online, how a lambda that + /// exists is changed, and the rules an agent would otherwise break before + /// it reads the guide. The rest is in platform_guide and the tools' + /// descriptions. + /// + internal static readonly string Instructions = string.Join('\n', + [ + "Hosts small C# web services: a snippet returns a GenHTTP handler, served at a public address.", + "", + "Start with platform_guide, then list_demos and read the demo closest to the task with read_lambda", + "(their keys are public and read only). Follow their patterns.", + "", + "A new lambda: create_lambda, then write_code with deploy: true - nothing is reachable before. Give", + "the user the editor key it returns: it cannot be recovered and grants write access.", + "", + "A lambda that exists is changed in a feature: create_feature, then change_code or write_code with", + "feature and deploy: true to try it at its preview address (read_logs with feature), then", + "merge_feature with deploy: true. Only a feature based on the newest version merges; if others were", + "saved since, bring their changes in and move its base with update_feature.", + "", + "A version is the program and never changes: its code - its .cs files, compiled in any folder, and", + "any other file, kept and never compiled or served - and its resources in resources/, what", + "it reads and serves (Resources.App(\"web\") serves resources/web/): the front end, its migrations. Data", + "- the database, the workspace, the secrets - is shared by every version and untouched by deploys", + "and merges; a feature works on a copy. Records go in the database and uploads in the workspace,", + "never in resources.", + "", + "Database: enable_data 'database'; the schema as Evolve migrations in resources/migrations/", + "(V1__Create_items.sql, never editing one that was applied, never EF's migrations or EnsureCreated);", + "records through Entity Framework Core on Database.GetConnection(), used synchronously -", + "never their Async forms. demo-crud shows it.", + "", + "Never wait for a task (.Result, .Wait(), .GetAwaiter().GetResult()): it deadlocks and is refused.", + "Await it, or call the synchronous method.", + "", + "Link with relative paths only (\"api/items\", no leading slash, never the full address): a lambda", + "answers at the root of its own address, but a feature at /features/{feature}/, where either leaves it.", + "", + "A page meant to be found or shared - a website, a shop, a landing page - gets a title, a meta", + "description, a favicon and a social preview, whose og:image takes a full address: platform_guide,", + "beingFound.", + "", + "Never poll for changes (fetch on a timer to refresh a page): push them from the server with", + "server-sent events (demo-live) or a websocket (demo-game) - platform_guide, liveUpdates.", + "", + "We ask for a small \"Made with GenHTTP Lambda\" line at the foot of the pages you build - a nofollow", + "link, plain text on a domain of its own - unless the user does not want it: platform_guide, backlink.", + "", + "API keys and passwords: Secret.Read(\"NAME\") and enable_data 'secrets'; the user enters the value", + "in the editor.", + "", + "Pass specification (what the user asked for and why, in their words) and change (one line for the", + "owner) with every write.", + "", + "Every version keeps docs/product.md (what the app is and for whom), docs/decisions.md (the", + "technical decisions and why) and tests/README.md (how to test it) in its code. Read them before", + "changing a lambda; write them with a new one and update what a change affects in the same save.", + "Keep them in proportion: a small lambda needs a few lines each and one quick check, not a test", + "suite - they grow with the app.", + "", + "What you build resources or code from with a tool goes in a folder of the code, built by you and", + "saved with what it built - nothing is built here: platform_guide, code.", + "", + "If you can make HTTP requests, the REST API (https://genhttp.dev/api/v1/openapi.json) does the same", + "with fewer tokens: download a feature as a zip, edit locally, put it back.", + "", + "If you can run git, clone the lambda instead (read_lambda's gitUrl) - AGENTS.md in it says how: a", + "commit pushed to main is a version, a branch pushed is a feature - platform_guide, git." + ]); + +} diff --git a/src/GenHTTP.Lambda/Api/Mcp/McpHandler.cs b/src/GenHTTP.Lambda/Api/Mcp/McpHandler.cs deleted file mode 100644 index 2438e641..00000000 --- a/src/GenHTTP.Lambda/Api/Mcp/McpHandler.cs +++ /dev/null @@ -1,300 +0,0 @@ -using System.Text; -using System.Text.Json.Nodes; - -using GenHTTP.Api.Content; -using GenHTTP.Api.Infrastructure; -using GenHTTP.Api.Protocol; - -using GenHTTP.Lambda.Web; - -using GenHTTP.Modules.IO; - -namespace GenHTTP.Lambda.Api.Mcp; - -/// -/// The Model Context Protocol endpoint, so an agent can build something here -/// and put it online without a person driving the editor. -/// -/// -/// One path, taking POST. Every message is JSON-RPC 2.0: a request is answered -/// with one JSON object, and a notification is acknowledged with 202 and no -/// body. The specification also allows answering over server sent events, and -/// this does not - nothing it does takes long enough to be worth streaming, -/// and a client has to support both. -/// -/// No session either. Everything a call needs is in its arguments, chiefly the -/// editor key, so there is nothing worth keeping between two of them and -/// nothing to lose when a process restarts. -/// -public sealed class McpHandler : IHandler -{ - private readonly McpTools _tools; - - private readonly HashSet _origins; - - public McpHandler(McpTools tools, IEnumerable origins) - { - _tools = tools; - _origins = new HashSet(origins, StringComparer.OrdinalIgnoreCase); - } - - public ValueTask PrepareAsync(IServer server) => ValueTask.CompletedTask; - - public async ValueTask HandleAsync(IRequest request) - { - if (request.Header.Method == RequestMethod.Get) - { - // the specification allows a server to offer a stream here and to - // say so with 405 when it does not - return Plain(request, ResponseStatus.MethodNotAllowed, - "This endpoint answers POST with JSON-RPC. It offers no event stream."); - } - - if (request.Header.Method != RequestMethod.Post) - { - return Plain(request, ResponseStatus.MethodNotAllowed, "POST a JSON-RPC message."); - } - - /* - * Origin is checked because a browser sends one and a page on some - * other site could otherwise drive this endpoint with somebody's - * credentials in the background. An agent speaking HTTP sends no - * Origin at all and is unaffected. - */ - var origin = request.Header.Headers.GetEntry("Origin"); - - if (origin != null && !Allowed(origin)) - { - return Plain(request, ResponseStatus.Forbidden, "That origin is not allowed to use this endpoint."); - } - - var version = request.Header.Headers.GetEntry("MCP-Protocol-Version"); - - if (version != null && !McpProtocol.Versions.Contains(version)) - { - return Plain(request, ResponseStatus.BadRequest, $"This server does not speak MCP '{version}'."); - } - - JsonNode? message; - - try - { - var content = request.HasBody ? request.GetBody() : null; - - var body = content == null ? [] : (await content.AsMemoryAsync()).ToArray(); - - message = body.Length == 0 ? null : JsonNode.Parse(Encoding.UTF8.GetString(body)); - - if (message == null) - { - return Json(request, ResponseStatus.BadRequest, - McpProtocol.Error(null, McpProtocol.InvalidRequest, "A message is one JSON-RPC object.")); - } - } - catch (Exception) - { - return Json(request, ResponseStatus.BadRequest, - McpProtocol.Error(null, McpProtocol.ParseError, "That is not JSON.")); - } - - if (message is not JsonObject call) - { - return Json(request, ResponseStatus.BadRequest, - McpProtocol.Error(null, McpProtocol.InvalidRequest, "A message is one JSON-RPC object.")); - } - - var id = call["id"]; - - var method = call["method"]?.GetValue(); - - if (method == null) - { - return Json(request, ResponseStatus.BadRequest, - McpProtocol.Error(id, McpProtocol.InvalidRequest, "A message needs a method.")); - } - - // a notification has no id and is owed no answer - if (id == null) - { - return Plain(request, ResponseStatus.Accepted, string.Empty); - } - - var parameters = call["params"] as JsonObject ?? []; - - var answer = await AnswerAsync(method, id, parameters, RequestOrigin.Of(request)); - - return Json(request, ResponseStatus.Ok, answer); - } - - private async ValueTask AnswerAsync(string method, JsonNode id, JsonObject parameters, string origin) - { - switch (method) - { - case "initialize": - return McpProtocol.Result(id, Introduce(parameters)); - - case "ping": - return McpProtocol.Result(id, new JsonObject()); - - case "tools/list": - return McpProtocol.Result(id, new JsonObject { ["tools"] = _tools.Describe() }); - - case "tools/call": - { - var name = parameters["name"]?.GetValue(); - - if (name == null) - { - return McpProtocol.Error(id, McpProtocol.InvalidParams, "A call needs the name of a tool."); - } - - var arguments = parameters["arguments"] as JsonObject ?? []; - - return McpProtocol.Result(id, await _tools.CallAsync(name, arguments, origin)); - } - - case "resources/list": - return McpProtocol.Result(id, new JsonObject { ["resources"] = new JsonArray() }); - - case "prompts/list": - return McpProtocol.Result(id, new JsonObject { ["prompts"] = new JsonArray() }); - - default: - return McpProtocol.Error(id, McpProtocol.MethodNotFound, $"This server has no method '{method}'."); - } - } - - /// - /// What this server is and what it can do. - /// - /// - /// The version asked for is echoed back when it is one we speak, and the - /// newest one we speak is offered otherwise. That is the negotiation the - /// specification describes: the client then decides whether it can live - /// with the answer. - /// - private static JsonObject Introduce(JsonObject parameters) - { - var asked = parameters["protocolVersion"]?.GetValue(); - - return new JsonObject - { - ["protocolVersion"] = asked != null && McpProtocol.Versions.Contains(asked) ? asked : McpProtocol.Latest, - ["capabilities"] = new JsonObject - { - ["tools"] = new JsonObject() - }, - ["serverInfo"] = new JsonObject - { - ["name"] = "genhttp-lambda", - ["title"] = "GenHTTP Lambda", - ["version"] = "1.0.0" - }, - ["instructions"] = string.Join('\n', - [ - "Hosts small C# web services: a snippet returns a GenHTTP handler, served at a public address.", - "", - "Start with platform_guide, then list_demos and read the demo closest to the task with read_lambda", - "(their keys are public and read only). Follow their patterns.", - "", - "A new lambda: create_lambda, then write_code with deploy: true - nothing is reachable before. Give", - "the user the editor key it returns: it cannot be recovered and grants write access.", - "", - "A lambda that exists is changed in a feature: create_feature, then change_code or write_code with", - "feature and deploy: true to try it at its preview address (read_logs with feature), then", - "merge_feature with deploy: true. Only a feature based on the newest version merges; if others were", - "saved since, bring their changes in and move its base with update_feature.", - "", - "A version is the program and never changes: its code - its .cs files, compiled in any folder, and", - "any other file, kept and never compiled or served - and its resources in resources/, what", - "it reads and serves (Resources.App(\"web\") serves resources/web/): the front end, its migrations. Data", - "- the database, the workspace, the secrets - is shared by every version and untouched by deploys", - "and merges; a feature works on a copy. Records go in the database and uploads in the workspace,", - "never in resources.", - "", - "Database: enable_data 'database'; the schema as Evolve migrations in resources/migrations/", - "(V1__Create_items.sql, never editing one that was applied, never EF's migrations or EnsureCreated);", - "records through Entity Framework Core on Database.GetConnection(), used synchronously -", - "never their Async forms. demo-crud shows it.", - "", - "Never wait for a task (.Result, .Wait(), .GetAwaiter().GetResult()): it deadlocks and is refused.", - "Await it, or call the synchronous method.", - "", - "Link with relative paths only (\"api/items\", no leading slash, never the full address): a lambda", - "answers at the root of its own address, but a feature at /features/{feature}/, where either leaves it.", - "", - "A page meant to be found or shared - a website, a shop, a landing page - gets a title, a meta", - "description, a favicon and a social preview, whose og:image takes a full address: platform_guide,", - "beingFound.", - "", - "Never poll for changes (fetch on a timer to refresh a page): push them from the server with", - "server-sent events (demo-live) or a websocket (demo-game) - platform_guide, liveUpdates.", - "", - "We ask for a small \"Made with GenHTTP Lambda\" line at the foot of the pages you build - a nofollow", - "link, plain text on a domain of its own - unless the user does not want it: platform_guide, backlink.", - "", - "API keys and passwords: Secret.Read(\"NAME\") and enable_data 'secrets'; the user enters the value", - "in the editor.", - "", - "Pass specification (what the user asked for and why, in their words) and change (one line for the", - "owner) with every write.", - "", - "Every version keeps docs/product.md (what the app is and for whom), docs/decisions.md (the", - "technical decisions and why) and tests/README.md (how to test it) in its code. Read them before", - "changing a lambda; write them with a new one and update what a change affects in the same save.", - "Keep them in proportion: a small lambda needs a few lines each and one quick check, not a test", - "suite - they grow with the app.", - "", - "What you build resources or code from with a tool goes in a folder of the code, built by you and", - "saved with what it built - nothing is built here: platform_guide, code.", - "", - "If you can make HTTP requests, the REST API (https://genhttp.dev/api/v1/openapi.json) does the same", - "with fewer tokens: download a feature as a zip, edit locally, put it back.", - "", - "If you can run git, clone the lambda instead (read_lambda's gitUrl) - AGENTS.md in it says how: a", - "commit pushed to main is a version, a branch pushed is a feature - platform_guide, git." - ]) - }; - } - - private bool Allowed(string origin) - { - if (!Uri.TryCreate(origin, UriKind.Absolute, out var parsed)) - { - return false; - } - - return _origins.Contains(parsed.Host); - } - - private static IResponse Json(IRequest request, ResponseStatus status, JsonObject payload) - => Answer(request, status, payload.ToJsonString(McpProtocol.Format), "application/json"); - - private static IResponse Plain(IRequest request, ResponseStatus status, string message) - => Answer(request, status, message, "text/plain; charset=utf-8"); - - private static IResponse Answer(IRequest request, ResponseStatus status, string body, string type) - => request.Respond() - .Status(status) - .Content(Resource.FromString(body).Type(new ContentType(type)).Build()) - .Build(); - -} - -/// -/// Puts the endpoint into a layout. -/// -public sealed class McpHandlerBuilder(McpTools tools, IEnumerable origins) : IHandlerBuilder -{ - private readonly List _concerns = []; - - public McpHandlerBuilder Add(IConcernBuilder concern) - { - _concerns.Add(concern); - - return this; - } - - public IHandler Build() => Concerns.Chain(_concerns, new McpHandler(tools, origins)); - -} diff --git a/src/GenHTTP.Lambda/Api/Mcp/McpProtocol.cs b/src/GenHTTP.Lambda/Api/Mcp/McpProtocol.cs deleted file mode 100644 index 2e3f78d1..00000000 --- a/src/GenHTTP.Lambda/Api/Mcp/McpProtocol.cs +++ /dev/null @@ -1,144 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Nodes; - -namespace GenHTTP.Lambda.Api.Mcp; - -/// -/// The bits of the Model Context Protocol this server speaks. -/// -/// -/// MCP is JSON-RPC 2.0 over a single HTTP endpoint. A server may answer a -/// request either with one JSON object or with a stream of server sent events; -/// this one always answers with the object, because everything it does is a -/// question and an answer and nothing here takes long enough to stream. -/// -public static class McpProtocol -{ - - /// - /// The revisions of the protocol this understands, newest first. - /// - /// - /// A client asking for one of these is answered with the same one. Anything - /// else is answered with the newest, which the client may then refuse - that - /// is the negotiation the specification describes, and it is better than - /// failing outright on a version we might well be compatible with. - /// - public static readonly string[] Versions = ["2025-06-18", "2025-03-26"]; - - public static string Latest => Versions[0]; - - public static readonly JsonSerializerOptions Format = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull - }; - - #region Errors - - public const int ParseError = -32700; - - public const int InvalidRequest = -32600; - - public const int MethodNotFound = -32601; - - public const int InvalidParams = -32602; - - public const int InternalError = -32603; - - #endregion - - #region Envelopes - - /// - /// A successful answer to a request. - /// - public static JsonObject Result(JsonNode? id, JsonNode? result) => new() - { - ["jsonrpc"] = "2.0", - ["id"] = id?.DeepClone(), - ["result"] = result - }; - - /// - /// A failed one. This is for the protocol going wrong - a method that does - /// not exist, arguments that do not fit. A tool that runs and fails says so - /// in its own result instead, so the model can read what went wrong and try - /// something else rather than the conversation ending in an error. - /// - public static JsonObject Error(JsonNode? id, int code, string message, JsonNode? data = null) - { - var error = new JsonObject - { - ["code"] = code, - ["message"] = message - }; - - if (data != null) - { - error["data"] = data; - } - - return new JsonObject - { - ["jsonrpc"] = "2.0", - ["id"] = id?.DeepClone(), - ["error"] = error - }; - } - - /// - /// What a tool gives back: text for the model to read, and the same thing - /// as data for anything that would rather parse it. - /// - public static JsonObject Say(object payload, bool failed = false) - { - var text = JsonSerializer.Serialize(payload, Format); - - return new JsonObject - { - ["content"] = new JsonArray(new JsonObject - { - ["type"] = "text", - ["text"] = text - }), - ["structuredContent"] = JsonNode.Parse(text), - ["isError"] = failed - }; - } - - /// - /// What a tool gives back when it could not do the thing it was asked. - /// - public static JsonObject Refuse(string message) => Say(new { ok = false, problem = message }, failed: true); - - /// - /// The same answer with one more thing said, in the text and in the data. - /// - /// - /// For what is worth saying however the call went on - a save that left - /// something out - so the several answers it may end in need not each - /// carry it. - /// - /// An answer of - /// Nothing to leave the answer as it is - public static JsonObject Adding(JsonObject said, string name, string? note) - { - if (note == null || said["structuredContent"] is not JsonObject data) - { - return said; - } - - data[name] = note; - - if (said["content"] is JsonArray { Count: > 0 } content && content[0] is JsonObject text) - { - text["text"] = data.ToJsonString(Format); - } - - return said; - } - - #endregion - -} diff --git a/src/GenHTTP.Lambda/Api/Mcp/McpTools.cs b/src/GenHTTP.Lambda/Api/Mcp/McpTools.cs index 9a5eac0d..a830cd95 100644 --- a/src/GenHTTP.Lambda/Api/Mcp/McpTools.cs +++ b/src/GenHTTP.Lambda/Api/Mcp/McpTools.cs @@ -1,6 +1,14 @@ -using System.Text.Json.Nodes; +using System.ComponentModel.DataAnnotations; +using System.Text.Json; +using System.Text.Json.Serialization; using System.Text.RegularExpressions; +using GenHTTP.Api.Content; +using GenHTTP.Api.Content.Documentation; +using GenHTTP.Api.Protocol; + +using GenHTTP.Modules.Mcp; + using GenHTTP.Lambda.Api.Model; using GenHTTP.Lambda.Configuration; using GenHTTP.Lambda.Data.Entities; @@ -21,6 +29,7 @@ using GenHTTP.Lambda.Services.Telemetry; using GenHTTP.Lambda.Services.Workspace; using GenHTTP.Lambda.Api.Infrastructure; +using GenHTTP.Lambda.Web; using Microsoft.Extensions.Logging; @@ -58,7 +67,9 @@ namespace GenHTTP.Lambda.Api.Mcp; /// same few tools do both, and nothing is learned twice. /// /// Every tool answers with an object rather than prose. A model reads the text -/// and a program reads the structured copy, and both are the same thing. +/// and a program reads the structured copy, and both are the same thing - the +/// MCP module writes both. A refusal of the platform is said as text, marked +/// as an error, like the module's own refusal of arguments that do not fit. /// /// Every tool that did something logs what it did, as the API does - reads /// included, since what an agent looks at is how it goes about its work. See @@ -70,504 +81,347 @@ public sealed class McpTools(IMetaService meta, IWorkspaceService workspace, IDa LambdaOptions options, ILimitsService tiers, ILambdaAddresses addresses, ILogger logger) { - #region Catalogue + #region Tools + + /* + * The tools as an agent sees them, in the order it reads them: the name, + * what the tool does, what it takes, and what calling it does to the + * platform. The parameters are the arguments and their schema, and an + * attribute on one - a range, a length, the values it takes - is + * advertised and checked before the tool runs. The work is done below. + * + * Every tool states all four hints. Clients read them to decide what to + * ask the user before a call - a read can go through, a deploy is worth a + * question - and without them every tool looks like the worst case, which + * is what the defaults assume. Four effects are told apart: a read looks + * and changes nothing; creating adds something and replaces nothing; + * saving adds a version or changes a feature, and with deploy: true + * replaces what is online; replacing replaces or removes what was there, + * the same way however often. None of them reaches beyond this platform. + */ + + [Tool("create_lambda", Title = "Create a lambda", ReadOnly = false, Destructive = false, Idempotent = false, OpenWorld = false)] + [Summary("Create a lambda. Returns its public address and a private editor key, the only way back in. It starts with version 1 - an empty starter, or a copy of a demo. Nothing is online until deploy.")] + public object CreateLambda(IRequest request, + [Summary("Must be true: the user accepts the terms in platform_guide (free shared machine, deployments may be removed, nothing malicious).")] bool acceptTerms, + [Summary("Requested address: lower case letters, digits and dashes. Generated if omitted.")] string? publicKey = null, + [Summary("Left out, the lambda starts empty. The id of a demo from list_demos starts it as a copy of that demo, which is yours to change.")] string? template = null, + [Summary(ViewField), EnumDataType(typeof(EditorView))] EditorView? view = null) + => Refusing(() => Create(acceptTerms, publicKey, template, view, RequestOrigin.Of(request))); + + [Tool("update_lambda", Title = "Change a lambda's settings", ReadOnly = false, Destructive = true, Idempotent = true, OpenWorld = false)] + [Summary("Change how a lambda's editor opens: view 'Simple' shows the app, how it is doing and a box to ask for a change - for an owner who does not write code - and 'Full' every section, the code, files, data, versions and logs included. Only the default: whoever opens the editor can switch for themselves, and that choice stays theirs. Only do this when the user asks for it.")] + public object UpdateLambda(IRequest request, + [Summary(PrivateKey)] string privateKey, + [Summary(ViewField), EnumDataType(typeof(EditorView))] EditorView view) + => Refusing(() => Update(privateKey, view, RequestOrigin.Of(request))); + + [Tool("write_code", Title = "Save all files", ReadOnly = false, Destructive = true, Idempotent = false, OpenWorld = false)] + [Summary("Save every file, replacing the previous set: as a new version of the lambda, or - with feature - into that feature. A version is its code and its resources. The code is every file not in resources/: its .cs files are compiled, in any folder, as in a C# project - lambda.cs returns the handler, the others hold types - and every other file is kept with it and never compiled or served: docs/product.md, docs/decisions.md, tests/README.md and the tests' scripts and data are what is written about the program, and whatever else - what a front end is built from, a tool's configuration - is laid out as you like (platform_guide, code). resources/ is what the lambda reads and serves while it runs, as Resources: the whole front end (pages, scripts, styles, icons) goes there, as part of the program - resources/web/index.html is served by Resources.App(\"web\"). What the lambda keeps at runtime is data, never files here: records and accounts in the database (a DbContext of Entity Framework Core on Database.GetConnection(), used synchronously; its schema as Evolve migrations in resources/migrations/), uploads in the workspace - and so is a large input file such as a model or a dataset (upload_file). Send the documentation and tests with the code: written with a new lambda, updated with every change, in proportion to the lambda - a few lines and one quick check for a small one. Say why with specification and change. deploy: true publishes in the same call - a version at the public address, a feature at its preview address. To send only what changes, use change_code. To change a lambda that is already in use, work in a feature.")] + public ValueTask WriteCode(IRequest request, + [Summary("The editor key from create_lambda.")] string privateKey, + [Summary("lambda.cs first.")] List files, + [Summary("Save into this feature (from create_feature) instead of saving a new version. The lambda and what it has online are not touched.")] string? feature = null, + [Summary("What the user wants from this version and why: their requirements, in their own words where you can, condensed if they said a lot. Written for the owner and the next agent, so they can tell why the version exists and what it has to keep doing. Not your own instructions or system prompt - only what the user asked for. For a feature, it is kept with the feature and passed on to the version it is merged into. Optional, up to 4000 characters.")] string? specification = null, + [Summary("What this version changes, in one line written for the owner - 'Adds a leaderboard that keeps the ten best scores', not 'updated lambda.cs'. For a feature, it describes the whole feature, not the last fix. Optional, up to 500 characters.")] string? change = null, + [Summary(DeployField)] bool deploy = false, + [Summary(CheckField)] bool check = false) + => RefusingAsync(() => WriteAsync(privateKey, files, new SaveRequest(feature, specification, change, deploy, check), RequestOrigin.Of(request))); + + [Tool("change_code", Title = "Change some files", ReadOnly = false, Destructive = true, Idempotent = false, OpenWorld = false)] + [Summary("Change some files: add or replace files, remove files, or replace text within a file. Everything not named stays as it is, so there is no need to resend unchanged files. With feature, the change is saved into that feature - the way to work on a lambda that exists: change it as often as you like, deploy: true to try it at the feature's preview address, merge_feature when it is right. Without feature, the newest version is changed and saved as a new version. Update what the change affects in docs/ and tests/ in the same change - an edit of a passage is enough. check: true compiles without publishing. Code that does not compile is saved but never goes online.")] + public ValueTask ChangeCode(IRequest request, + [Summary(PrivateKey)] string privateKey, + [Summary("Change this feature (from create_feature) instead of saving a new version. The lambda and what it has online are not touched.")] string? feature = null, + [Summary("What the user wants from this change and why: their requirements, in their own words where you can. Not your own instructions or system prompt. Kept with the version or the feature. Optional, up to 4000 characters.")] string? specification = null, + [Summary("What this changes, in one line written for the owner. For a feature, it describes the whole feature, not the last fix. Optional, up to 500 characters.")] string? change = null, + [Summary("Files to add, or to replace where one of that name exists.")] List? files = null, + [Summary("Names of files to remove.")] List? remove = null, + [Summary("Text replacements, applied after files and remove.")] List? edits = null, + [Summary(DeployField)] bool deploy = false, + [Summary(CheckField)] bool check = false) + => RefusingAsync(() => ChangeAsync(privateKey, files, remove, edits, new SaveRequest(feature, specification, change, deploy, check), RequestOrigin.Of(request))); + + [Tool("create_feature", Title = "Start a feature", ReadOnly = false, Destructive = false, Idempotent = false, OpenWorld = false)] + [Summary("Start a feature: a place to change a lambda without touching what it has online. It branches off a version - the newest unless base names another - with a copy of its files (its documentation and tests included) and a copy of the lambda's data, and gets an address of its own to try it at. Change it with change_code or write_code and feature (deploy: true puts it online at its preview address), test it there, and merge_feature once it does what was asked. The lambda goes on serving its visitors from the version online the whole time.")] + public ValueTask CreateFeature(IRequest request, + [Summary(PrivateKey)] string privateKey, + [Summary("What the feature is, in a few words for the owner - 'Leaderboard', 'Dark mode'."), MaxLength(FeatureService.MaxName)] string name, + [Summary("What the user wants from it and why, in their words where you can. Kept with the feature and passed on to the version it is merged into. Optional, up to 4000 characters.")] string? specification = null, + [Summary("The version to branch off. Defaults to the newest - leave it out unless the user asked to start from an older one.")] int? @base = null) + => RefusingAsync(() => CreateFeatureAsync(privateKey, name, specification, @base, RequestOrigin.Of(request))); + + [Tool("update_feature", Title = "Change a feature's name, notes or base", ReadOnly = false, Destructive = true, Idempotent = true, OpenWorld = false)] + [Summary("Rename a feature, change what it says about itself, or move its base. merge_feature refuses a feature that is not based on the newest version, since merging it would undo what was saved after it branched off: bring the newer versions' changes into the feature first, then move base to the newest version here. Nothing checks that the changes really are in - that is up to you.")] + public object UpdateFeature(IRequest request, + [Summary(PrivateKey)] string privateKey, + [Summary("The feature, from create_feature or read_lambda.")] string feature, + [Summary("A new name."), MaxLength(FeatureService.MaxName)] string? name = null, + [Summary("What the user wants from it and why.")] string? specification = null, + [Summary("What it changes, in one line - what the version it is merged into will say.")] string? change = null, + [Summary("The version the feature is now based on, once that version's changes are in its files.")] int? @base = null) + => Refusing(() => ChangeFeature(privateKey, feature, new FeatureUpdate(name, specification, change, @base), RequestOrigin.Of(request))); + + [Tool("merge_feature", Title = "Merge a feature into the lambda", ReadOnly = false, Destructive = true, Idempotent = false, OpenWorld = false)] + [Summary("Make a feature's files the next version of the lambda, and delete the feature with its preview and its copy of the data - the lambda's own data is not touched. Its docs/ and tests/ become the version's documentation and tests, so bring them up to date in the feature first, and run its tests against the preview. Refused while the feature is not based on the newest version (update_feature says how to get it there), and while its code does not compile. deploy: true puts the new version online at once; otherwise deploy it when the user wants it live.")] + public ValueTask MergeFeature([Summary(PrivateKey)] string privateKey, + [Summary("The feature.")] string feature, + [Summary("What the new version keeps as what the user wanted. Left out, the feature's.")] string? specification = null, + [Summary("What the new version says it changes. Left out, the feature's.")] string? change = null, + [Summary("Also put the new version online.")] bool deploy = false) + => RefusingAsync(() => MergeFeatureAsync(privateKey, feature, specification, change, deploy)); + + [Tool("delete_feature", Title = "Delete a feature", ReadOnly = false, Destructive = true, Idempotent = true, OpenWorld = false)] + [Summary("Throw a feature away: its files, its preview and its copy of the data. The lambda is not touched. For a feature the user does not want after all.")] + public object DeleteFeature([Summary(PrivateKey)] string privateKey, + [Summary("The feature.")] string feature) + => Refusing(() => RemoveFeature(privateKey, feature)); + + [Tool("check_code", Title = "Check that code compiles", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false)] + [Summary("Compile without saving or deploying; returns diagnostics with file and line. Does not build the handler, so deploy can still refuse a route whose return type cannot be served.")] + public ValueTask CheckCode([Summary(PrivateKey)] string privateKey, + [Summary("lambda.cs first. The .cs files of the code are compiled, in any folder; nothing else is - send those, the rest may be left out.")] List files) + => RefusingAsync(() => CheckAsync(privateKey, files)); + + [Tool("deploy", Title = "Deploy a version or a feature", ReadOnly = false, Destructive = true, Idempotent = true, OpenWorld = false)] + [Summary("Deploy (publish) a saved version so it goes live at its public address - the newest by default. With feature, deploy that feature's files to its own preview address instead, against its copy of the data, leaving the lambda alone. Returns diagnostics on failure, and whatever was online stays online.")] + public ValueTask Deploy(IRequest request, + [Summary(PrivateKey)] string privateKey, + [Summary("Defaults to the newest.")] int? version = null, + [Summary("Deploy this feature to its preview address instead of a version to the lambda's.")] string? feature = null) + => RefusingAsync(() => feature != null + ? DeployFeatureAsync(privateKey, feature, RequestOrigin.Of(request)) + : DeployVersionAsync(privateKey, version, RequestOrigin.Of(request))); + + [Tool("read_lambda", Title = "Read a lambda", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false)] + [Summary("A lambda's status (online version, newest version, expiry, its tier and what it may use there), its open features, the recent versions with what each was asked for and changed, its data (the database, the workspace and the secrets: whether each is on, what it holds, and which secrets the code reads that have no value yet), and one version - or, with feature, that feature: its documentation (docs/: what the app is and why, the technical decisions), how it is tested (tests/), and its files. The documentation comes first and in full up to 20,000 characters; the files come in full while they fit into 30,000 characters - the C# first, then the resources, then the rest of the code - and by name and length after that, with file to read one - a test script or its data too. Read the documentation and the history before changing what you did not write. Also how a demo is read: pass its key from list_demos.")] + public object ReadLambda(IRequest request, + [Summary(PrivateKeyOrDemo)] string privateKey, + [Summary("Defaults to the newest.")] int? version = null, + [Summary("Read this feature - its files, its base, and which newer versions it would have to take in before it can be merged - instead of a version.")] string? feature = null, + [Summary("Return only this file, in full however large the rest is - up to 1,048,576 characters, beyond which the zip has it. Any file of the version, 'tests/smoke.mjs' and 'resources/web/index.html' included.")] string? file = null) + => Refusing(() => Read(privateKey, version, feature, file, RequestOrigin.Of(request))); + + [Tool("read_logs", Title = "Read a lambda's logs", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false)] + [Summary("What a deployed lambda has been doing: its recent requests and how they were answered, what it printed, the errors it threw with their stack traces, and how much traffic it has had in the last hour and day. With feature, what that feature's preview has been doing instead, kept apart from the lambda's visitors. Call it after deploying to see that it works, and first when something is reported broken.")] + public object ReadLogs([Summary(PrivateKeyOrDemo)] string privateKey, + [Summary("Read what this feature's preview did instead.")] string? feature = null, + [Summary("The lowest level worth reading: 'info' for everything, 'warn' for problems, 'error' for failures."), AllowedValues("debug", "info", "warn", "warning", "error")] string level = "info", + [Summary("The cursor a previous call answered with, to read only what is new since then.")] long since = 0, + [Summary("At most this many lines, the newest."), Range(1, 1000)] int limit = 100) + => Refusing(() => Logs(privateKey, feature, level, since, limit)); + + [Tool("upload_file", Title = "Put a file into the lambda's data", ReadOnly = false, Destructive = true, Idempotent = true, OpenWorld = false)] + [Summary("Write a file to the lambda's workspace - its data, which every version shares and no deploy, rollback or merge touches. With feature, to that feature's copy of the data instead, for trying things out without touching the real one. For files the lambda works with at runtime (pictures people will browse) and large input files that are not program: a model, a dataset, media. Records belong in the database, where the code writes them. Takes effect at once, without a deploy. Not for the front end: pages, scripts and styles are the program and belong in the version's resources (write_code).")] + public ValueTask UploadFile([Summary(PrivateKey)] string privateKey, + [Summary("Relative to the workspace; slashes make folders, e.g. 'models/model.onnx'.")] string path, + [Summary("Text, or base64 with encoding set.")] string content, + [Summary("Write to this feature's copy of the data instead.")] string? feature = null, + [Summary("'base64' for binary; omit otherwise.")] string? encoding = null) + => RefusingAsync(() => UploadAsync(privateKey, path, content, feature, encoding)); + + [Tool("list_files", Title = "List the lambda's data", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false)] + [Summary("The lambda's data: whether its workspace is switched on, and every file in it with size and last write - the same whichever version is online. With feature, that feature's copy of it. The code and resources of a version are in read_lambda.")] + public object ListFiles([Summary(PrivateKeyOrDemo)] string privateKey, + [Summary("List this feature's copy of the data instead.")] string? feature = null) + => Refusing(() => Files(privateKey, feature)); + + [Tool("delete_file", Title = "Delete a file from the lambda's data", ReadOnly = false, Destructive = true, Idempotent = true, OpenWorld = false)] + [Summary("Remove a file, or a folder with its contents, from the workspace - the lambda's data, shared by every version. No deploy or rollback brings it back. With feature, from that feature's copy instead.")] + public object DeleteFile([Summary(PrivateKey)] string privateKey, + [Summary("Relative to the workspace.")] string path, + [Summary("Delete from this feature's copy of the data instead.")] string? feature = null) + => Refusing(() => Remove(privateKey, path, feature)); + + [Tool("enable_data", Title = "Switch on a kind of data", ReadOnly = false, Destructive = true, Idempotent = true, OpenWorld = false)] + [Summary("Switch on a kind of data the lambda needs and does not have: 'database' (a SQLite database for its records - off until switched on, which makes it, empty), 'secrets' (off until switched on) or 'workspace' (on unless the owner switched it off). Do it when what you build needs it - records need the database, an API key for a service it calls needs secrets. Takes effect at once, without a deploy; switching the database on starts the lambda again on its next request, so its migrations run. There is no tool to switch one off: that deletes what it held, and is the owner's to do in the editor.")] + public object EnableData([Summary(PrivateKey)] string privateKey, + [Summary("Which kind."), AllowedValues(DataKinds.DatabaseId, DataKinds.SecretsId, DataKinds.WorkspaceId)] string kind) + => Refusing(() => SwitchOn(privateKey, kind)); + + [Tool("read_database", Title = "Read the lambda's database", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false)] + [Summary("The lambda's database - its records, shared by every version: whether it is switched on, how full it is, and its tables and views with their columns and how many rows each holds. With table, a page of that table's rows, newest first, up to 100 at a time. Read only: the lambda writes it, through Database.GetConnection(). Use it to see that migrations were applied and records were written. With feature, that feature's copy.")] + public ValueTask ReadDatabase([Summary(PrivateKeyOrDemo)] string privateKey, + [Summary("Read this feature's copy of the database instead.")] string? feature = null, + [Summary("A table or view to read the rows of; left out, the tables are listed.")] string? table = null, + [Summary("Rows to skip, for the next page."), Range(0, int.MaxValue)] int offset = 0, + [Summary("Rows to read."), Range(1, MaxDatabaseRows)] int limit = 20, + [Summary("A column to sort by; left out, the order the rows were written in.")] string? order = null, + [Summary("Oldest or smallest first, rather than newest or largest.")] bool ascending = false) + => RefusingAsync(() => DatabaseAsync(privateKey, feature, table, offset, limit, order, ascending)); + + [Tool("set_secret", Title = "Store a secret", ReadOnly = false, Destructive = true, Idempotent = true, OpenWorld = false)] + [Summary("Store an API key, password or token under a name the code reads with Secret.Read(\"NAME\") - never put one in the code, a resource, the workspace, a log line or a note. The value is sealed and can never be read back, by you or the owner; only the lambda reads it, from its next call on, without a deploy. Only set a value the user gave you for this: when you do not have it, write the code with Secret.Read and tell the user to set it under Data > Secrets in the editor - read_lambda and list_secrets list what is still missing. Secrets must be switched on first (enable_data). With feature, into that feature's copy (a sandbox key to try it with). Up to 100 secrets of 32 KB each.")] + public object SetSecret([Summary(PrivateKey)] string privateKey, + [Summary("Letters, digits and underscores, not starting with a digit - conventionally upper case: 'STRIPE_KEY'. The name of an environment variable in an exported project.")] string name, + [Summary("The value, as the user gave it.")] string value, + [Summary("Store it in this feature's copy of the secrets instead.")] string? feature = null) + => Refusing(() => StoreSecret(privateKey, name, value, feature)); + + [Tool("list_secrets", Title = "List a lambda's secrets", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false)] + [Summary("The lambda's secrets by name - never their values: whether secrets are switched on, what is stored and when, which names the code reads with Secret.Read and have no value yet (missing) - what the user still has to set - and which it only uses when they are there, checked with Secret.Exists (optional). With feature, that feature's copy.")] + public object ListSecrets([Summary(PrivateKeyOrDemo)] string privateKey, + [Summary("List this feature's copy instead.")] string? feature = null) + => Refusing(() => Secrets(privateKey, feature)); + + [Tool("delete_secret", Title = "Delete a secret", ReadOnly = false, Destructive = true, Idempotent = true, OpenWorld = false)] + [Summary("Remove a secret. Code that reads it with Secret.Read fails from its next call on. Only when the user asks for it, or a secret you stored yourself is no longer used. With feature, from that feature's copy instead.")] + public object DeleteSecret([Summary(PrivateKey)] string privateKey, + [Summary("The name, as stored - names are case sensitive.")] string name, + [Summary("Delete from this feature's copy instead.")] string? feature = null) + => Refusing(() => RemoveSecret(privateKey, name, feature)); + + [Tool("showcase", Title = "Showcase a lambda", ReadOnly = false, Destructive = true, Idempotent = true, OpenWorld = false)] + [Summary("List a lambda on the public showcase page, change its entry, or take it off. Not part of building: only do this when the user asks for it. With only privateKey it returns the current entry. An entry needs a title, a description and a picture (a screenshot or short GIF of the lambda in use); it is listed while the lambda is online. " + ShowcaseLimits.Tone)] + public object Showcase(IRequest request, + [Summary(PrivateKey)] string privateKey, + [Summary("What it is, in a few words - 'Pub quiz scoreboard', not 'The ultimate quiz experience'."), MaxLength(ShowcaseLimits.MaxTitle)] string? title = null, + [Summary("One to three plain sentences on what a visitor can do with it."), MaxLength(ShowcaseLimits.MaxDescription)] string? description = null, + [Summary("The picture, base64: PNG, JPEG, GIF or WebP, up to the size platform_guide names under showcase. Needed for a new entry; left out, the current one is kept.")] string? image = null, + [Summary("Take the lambda off the showcase instead.")] bool remove = false) + => Refusing(() => ChangeShowcase(privateKey, title, description, image, remove, RequestOrigin.Of(request))); + + [Tool("open_source", Title = "Publish a lambda's source code", ReadOnly = false, Destructive = true, Idempotent = true, OpenWorld = false)] + [Summary("Publish a lambda's source code at /source/{publicKey} for anybody to read, star and download as a .NET project, change the license it is published under, or take it down. What is published is the program and what is written about it - every version's code, resources, documentation and tests, and the history of what each changed - never its data: not the database, not the workspace, not a secret's value. Not part of building: only do this when the user asks for it, and ask which license if they did not say (MIT unless they want another). With only privateKey it returns whether it is published. Once published, everything in every version is public, older ones included: keep keys, passwords and personal data out of the files - in secrets and the database.")] + public object OpenSource(IRequest request, + [Summary(PrivateKey)] string privateKey, + [Summary(LicenseField)] string? license = null, + [Summary("Who holds the copyright, as the license names them - the user's name or organization, only if they gave it. Left out, it stays as it is; empty, the license names 'the authors of' the lambda."), MaxLength(SourceLicenses.MaxAuthor)] string? author = null, + [Summary("Take the source down instead. Its stars are kept for when it is published again.")] bool remove = false) + => Refusing(() => ChangeSource(privateKey, license, author, remove, RequestOrigin.Of(request))); + + [Tool("analytics", Title = "Read who visits a lambda", ReadOnly = false, Destructive = true, Idempotent = true, OpenWorld = false)] + [Summary("How people reach a lambda, as its owner sees it in the editor: visitors, pages opened and requests per day, and the pages, referring sites, countries, devices, browsers, operating systems and crawlers (search engines, AIs, link previews) behind them. Counted by day in UTC and written down every few minutes; a year is kept, of which a free lambda shows the last 7 days and a premium one all. Off until switched on - nothing is counted before. enable: true switches it on, only when the user asks; switching off deletes what was counted and is the owner's to do in the editor. Use it to answer what the user asks about their visitors, or to see which pages matter before changing them.")] + public object Analytics([Summary(PrivateKey)] string privateKey, + [Summary("How many days, today included. Left out, as many as the lambda's tier shows, which is also the most.")] int? days = null, + [Summary("Switch analytics on, counting from the next request - only when the user asked for it.")] bool enable = false) + => Refusing(() => ReadAnalytics(privateKey, days, enable)); + + [Tool("list_demos", Title = "List the demos", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false)] + [Summary("Demos this platform keeps online, each a finished lambda showing one way to build something: a REST API over records, registration and login, a websocket game, uploads, live updates. Their keys are public and read only: read the closest one with read_lambda (and list_files, read_logs) before writing similar code. create_lambda with a demo's id as template starts from a copy.")] + public object ListDemos() + { + logger.LogInformation("Listed demos"); - /// - /// The tools, as the protocol describes them. - /// - public JsonArray Describe() => - [ - Tool("create_lambda", "Create a lambda", Effect.Create, - "Create a lambda. Returns its public address and a private editor key, the only way back in. It starts with version 1 - an empty starter, or a copy of a demo. Nothing is online until deploy.", - new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["publicKey"] = Field("string", "Requested address: lower case letters, digits and dashes. Generated if omitted."), - ["template"] = Field("string", $"Left out, the lambda starts empty. The id of a demo ({string.Join(", ", DemoCatalog.All.Select(d => d.Id))}) starts it as a copy of that demo, which is yours to change."), - ["acceptTerms"] = Field("boolean", "Must be true: the user accepts the terms in platform_guide (free shared machine, deployments may be removed, nothing malicious)."), - ["view"] = Field("string", ViewField) - }, - ["required"] = new JsonArray("acceptTerms") - }), - - Tool("update_lambda", "Change a lambda's settings", Effect.Replace, - "Change how a lambda's editor opens: view 'Simple' shows the app, how it is doing and a box to ask for a change - for an owner who does not write code - and 'Full' every section, the code, files, data, versions and logs included. Only the default: whoever opens the editor can switch for themselves, and that choice stays theirs. Only do this when the user asks for it.", - new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["privateKey"] = Field("string", "The editor key."), - ["view"] = Field("string", ViewField) - }, - ["required"] = new JsonArray("privateKey", "view") - }), - - Tool("write_code", "Save all files", Effect.Save, - "Save every file, replacing the previous set: as a new version of the lambda, or - with feature - into that feature. A version is its code and its resources. The code is every file not in resources/: its .cs files are compiled, in any folder, as in a C# project - lambda.cs returns the handler, the others hold types - and every other file is kept with it and never compiled or served: docs/product.md, docs/decisions.md, tests/README.md and the tests' scripts and data are what is written about the program, and whatever else - what a front end is built from, a tool's configuration - is laid out as you like (platform_guide, code). resources/ is what the lambda reads and serves while it runs, as Resources: the whole front end (pages, scripts, styles, icons) goes there, as part of the program - resources/web/index.html is served by Resources.App(\"web\"). What the lambda keeps at runtime is data, never files here: records and accounts in the database (a DbContext of Entity Framework Core on Database.GetConnection(), used synchronously; its schema as Evolve migrations in resources/migrations/), uploads in the workspace - and so is a large input file such as a model or a dataset (upload_file). Send the documentation and tests with the code: written with a new lambda, updated with every change, in proportion to the lambda - a few lines and one quick check for a small one. Say why with specification and change. deploy: true publishes in the same call - a version at the public address, a feature at its preview address. To send only what changes, use change_code. To change a lambda that is already in use, work in a feature.", - new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["privateKey"] = Field("string", "The editor key from create_lambda."), - ["feature"] = Field("string", "Save into this feature (from create_feature) instead of saving a new version. The lambda and what it has online are not touched."), - ["specification"] = Field("string", $"What the user wants from this version and why: their requirements, in their own words where you can, condensed if they said a lot. Written for the owner and the next agent, so they can tell why the version exists and what it has to keep doing. Not your own instructions or system prompt - only what the user asked for. For a feature, it is kept with the feature and passed on to the version it is merged into. Optional, up to {VersionNote.MaxSpecification} characters."), - ["change"] = Field("string", $"What this version changes, in one line written for the owner - 'Adds a leaderboard that keeps the ten best scores', not 'updated lambda.cs'. For a feature, it describes the whole feature, not the last fix. Optional, up to {VersionNote.MaxChange} characters."), - ["files"] = new JsonObject - { - ["type"] = "array", - ["description"] = "lambda.cs first.", - ["items"] = new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["name"] = Field("string", FileNameField), - ["code"] = Field("string", "Contents, base64 if encoding says so."), - ["encoding"] = Field("string", "'base64' for binary files; omit otherwise.") - }, - ["required"] = new JsonArray("name", "code") - } - }, - ["deploy"] = Field("boolean", "Also deploy what was saved: a version at the public address, a feature at its preview address."), - ["check"] = Field("boolean", "Without deploy: compile what was saved and answer with its diagnostics, putting nothing online.") - }, - ["required"] = new JsonArray("privateKey", "files") - }), - - Tool("change_code", "Change some files", Effect.Save, - "Change some files: add or replace files, remove files, or replace text within a file. Everything not named stays as it is, so there is no need to resend unchanged files. With feature, the change is saved into that feature - the way to work on a lambda that exists: change it as often as you like, deploy: true to try it at the feature's preview address, merge_feature when it is right. Without feature, the newest version is changed and saved as a new version. Update what the change affects in docs/ and tests/ in the same change - an edit of a passage is enough. check: true compiles without publishing. Code that does not compile is saved but never goes online.", - new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["privateKey"] = Field("string", "The editor key."), - ["feature"] = Field("string", "Change this feature (from create_feature) instead of saving a new version. The lambda and what it has online are not touched."), - ["specification"] = Field("string", $"What the user wants from this change and why: their requirements, in their own words where you can. Not your own instructions or system prompt. Kept with the version or the feature. Optional, up to {VersionNote.MaxSpecification} characters."), - ["change"] = Field("string", $"What this changes, in one line written for the owner. For a feature, it describes the whole feature, not the last fix. Optional, up to {VersionNote.MaxChange} characters."), - ["files"] = new JsonObject - { - ["type"] = "array", - ["description"] = "Files to add, or to replace where one of that name exists.", - ["items"] = new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["name"] = Field("string", FileNameField), - ["code"] = Field("string", "Contents, base64 if encoding says so."), - ["encoding"] = Field("string", "'base64' for binary files; omit otherwise.") - }, - ["required"] = new JsonArray("name", "code") - } - }, - ["remove"] = new JsonObject - { - ["type"] = "array", - ["description"] = "Names of files to remove.", - ["items"] = new JsonObject { ["type"] = "string" } - }, - ["edits"] = new JsonObject - { - ["type"] = "array", - ["description"] = "Text replacements, applied after files and remove.", - ["items"] = new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["file"] = Field("string", "The file to change."), - ["find"] = Field("string", "Text that occurs exactly once in the file."), - ["replace"] = Field("string", "What to put in its place.") - }, - ["required"] = new JsonArray("file", "find", "replace") - } - }, - ["deploy"] = Field("boolean", "Also deploy what was saved: a version at the public address, a feature at its preview address."), - ["check"] = Field("boolean", "Without deploy: compile what was saved and answer with its diagnostics, putting nothing online.") - }, - ["required"] = new JsonArray("privateKey") - }), - - Tool("create_feature", "Start a feature", Effect.Create, - "Start a feature: a place to change a lambda without touching what it has online. It branches off a version - the newest unless base names another - with a copy of its files (its documentation and tests included) and a copy of the lambda's data, and gets an address of its own to try it at. Change it with change_code or write_code and feature (deploy: true puts it online at its preview address), test it there, and merge_feature once it does what was asked. The lambda goes on serving its visitors from the version online the whole time.", - new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["privateKey"] = Field("string", "The editor key."), - ["name"] = Field("string", $"What the feature is, in a few words for the owner - 'Leaderboard', 'Dark mode'. Up to {FeatureService.MaxName} characters."), - ["specification"] = Field("string", $"What the user wants from it and why, in their words where you can. Kept with the feature and passed on to the version it is merged into. Optional, up to {VersionNote.MaxSpecification} characters."), - ["base"] = Field("integer", "The version to branch off. Defaults to the newest - leave it out unless the user asked to start from an older one.") - }, - ["required"] = new JsonArray("privateKey", "name") - }), - - Tool("update_feature", "Change a feature's name, notes or base", Effect.Replace, - "Rename a feature, change what it says about itself, or move its base. merge_feature refuses a feature that is not based on the newest version, since merging it would undo what was saved after it branched off: bring the newer versions' changes into the feature first, then move base to the newest version here. Nothing checks that the changes really are in - that is up to you.", - new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["privateKey"] = Field("string", "The editor key."), - ["feature"] = Field("string", "The feature, from create_feature or read_lambda."), - ["name"] = Field("string", "A new name."), - ["specification"] = Field("string", "What the user wants from it and why."), - ["change"] = Field("string", "What it changes, in one line - what the version it is merged into will say."), - ["base"] = Field("integer", "The version the feature is now based on, once that version's changes are in its files.") - }, - ["required"] = new JsonArray("privateKey", "feature") - }), - - Tool("merge_feature", "Merge a feature into the lambda", Effect.Save, - "Make a feature's files the next version of the lambda, and delete the feature with its preview and its copy of the data - the lambda's own data is not touched. Its docs/ and tests/ become the version's documentation and tests, so bring them up to date in the feature first, and run its tests against the preview. Refused while the feature is not based on the newest version (update_feature says how to get it there), and while its code does not compile. deploy: true puts the new version online at once; otherwise deploy it when the user wants it live.", - new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["privateKey"] = Field("string", "The editor key."), - ["feature"] = Field("string", "The feature."), - ["specification"] = Field("string", "What the new version keeps as what the user wanted. Left out, the feature's."), - ["change"] = Field("string", "What the new version says it changes. Left out, the feature's."), - ["deploy"] = Field("boolean", "Also put the new version online.") - }, - ["required"] = new JsonArray("privateKey", "feature") - }), - - Tool("delete_feature", "Delete a feature", Effect.Replace, - "Throw a feature away: its files, its preview and its copy of the data. The lambda is not touched. For a feature the user does not want after all.", - new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["privateKey"] = Field("string", "The editor key."), - ["feature"] = Field("string", "The feature.") - }, - ["required"] = new JsonArray("privateKey", "feature") - }), - - Tool("check_code", "Check that code compiles", Effect.Read, - "Compile without saving or deploying; returns diagnostics with file and line. Does not build the handler, so deploy can still refuse a route whose return type cannot be served.", - new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["privateKey"] = Field("string", "The editor key."), - ["files"] = new JsonObject - { - ["type"] = "array", - ["description"] = "lambda.cs first.", - ["items"] = new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["name"] = Field("string", "The .cs files of the code are compiled, in any folder; nothing else is - send those, the rest may be left out."), - ["code"] = Field("string", "Contents, base64 if encoding says so."), - ["encoding"] = Field("string", "'base64' for binary files; omit otherwise.") - }, - ["required"] = new JsonArray("name", "code") - } - } - }, - ["required"] = new JsonArray("privateKey", "files") - }), - - Tool("deploy", "Deploy a version or a feature", Effect.Replace, - "Deploy (publish) a saved version so it goes live at its public address - the newest by default. With feature, deploy that feature's files to its own preview address instead, against its copy of the data, leaving the lambda alone. Returns diagnostics on failure, and whatever was online stays online.", - new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["privateKey"] = Field("string", "The editor key."), - ["version"] = Field("integer", "Defaults to the newest."), - ["feature"] = Field("string", "Deploy this feature to its preview address instead of a version to the lambda's.") - }, - ["required"] = new JsonArray("privateKey") - }), - - Tool("read_lambda", "Read a lambda", Effect.Read, - $"A lambda's status (online version, newest version, expiry, its tier and what it may use there), its open features, the recent versions with what each was asked for and changed, its data (the database, the workspace and the secrets: whether each is on, what it holds, and which secrets the code reads that have no value yet), and one version - or, with feature, that feature: its documentation (docs/: what the app is and why, the technical decisions), how it is tested (tests/), and its files. The documentation comes first and in full up to {ContextBudget:N0} characters; the files come in full while they fit into {ReadBudget:N0} characters - the C# first, then the resources, then the rest of the code - and by name and length after that, with file to read one - a test script or its data too. Read the documentation and the history before changing what you did not write. Also how a demo is read: pass its key from list_demos.", - new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["privateKey"] = Field("string", "The editor key, or the key of a demo."), - ["version"] = Field("integer", "Defaults to the newest."), - ["feature"] = Field("string", "Read this feature - its files, its base, and which newer versions it would have to take in before it can be merged - instead of a version."), - ["file"] = Field("string", $"Return only this file, in full however large the rest is - up to {ReadFileLimit:N0} characters, beyond which the zip has it. Any file of the version, 'tests/smoke.mjs' and 'resources/web/index.html' included.") - }, - ["required"] = new JsonArray("privateKey") - }), - - Tool("read_logs", "Read a lambda's logs", Effect.Read, - "What a deployed lambda has been doing: its recent requests and how they were answered, what it printed, the errors it threw with their stack traces, and how much traffic it has had in the last hour and day. With feature, what that feature's preview has been doing instead, kept apart from the lambda's visitors. Call it after deploying to see that it works, and first when something is reported broken.", - new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["privateKey"] = Field("string", "The editor key, or the key of a demo."), - ["feature"] = Field("string", "Read what this feature's preview did instead."), - ["level"] = Field("string", "The lowest level worth reading: 'info' for everything, 'warn' for problems, 'error' for failures. Left out, 'info'."), - ["since"] = Field("integer", "The cursor a previous call answered with, to read only what is new since then."), - ["limit"] = Field("integer", "At most this many lines, the newest. Left out, 100.") - }, - ["required"] = new JsonArray("privateKey") - }), - - Tool("upload_file", "Put a file into the lambda's data", Effect.Replace, - "Write a file to the lambda's workspace - its data, which every version shares and no deploy, rollback or merge touches. With feature, to that feature's copy of the data instead, for trying things out without touching the real one. For files the lambda works with at runtime (pictures people will browse) and large input files that are not program: a model, a dataset, media. Records belong in the database, where the code writes them. Takes effect at once, without a deploy. Not for the front end: pages, scripts and styles are the program and belong in the version's resources (write_code).", - new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["privateKey"] = Field("string", "The editor key."), - ["feature"] = Field("string", "Write to this feature's copy of the data instead."), - ["path"] = Field("string", "Relative to the workspace; slashes make folders, e.g. 'models/model.onnx'."), - ["content"] = Field("string", "Text, or base64 with encoding set."), - ["encoding"] = Field("string", "'base64' for binary; omit otherwise.") - }, - ["required"] = new JsonArray("privateKey", "path", "content") - }), - - Tool("list_files", "List the lambda's data", Effect.Read, - "The lambda's data: whether its workspace is switched on, and every file in it with size and last write - the same whichever version is online. With feature, that feature's copy of it. The code and resources of a version are in read_lambda.", - new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["privateKey"] = Field("string", "The editor key, or the key of a demo."), - ["feature"] = Field("string", "List this feature's copy of the data instead.") - }, - ["required"] = new JsonArray("privateKey") - }), - - Tool("delete_file", "Delete a file from the lambda's data", Effect.Replace, - "Remove a file, or a folder with its contents, from the workspace - the lambda's data, shared by every version. No deploy or rollback brings it back. With feature, from that feature's copy instead.", - new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["privateKey"] = Field("string", "The editor key."), - ["feature"] = Field("string", "Delete from this feature's copy of the data instead."), - ["path"] = Field("string", "Relative to the workspace.") - }, - ["required"] = new JsonArray("privateKey", "path") - }), - - Tool("enable_data", "Switch on a kind of data", Effect.Replace, - "Switch on a kind of data the lambda needs and does not have: 'database' (a SQLite database for its records - off until switched on, which makes it, empty), 'secrets' (off until switched on) or 'workspace' (on unless the owner switched it off). Do it when what you build needs it - records need the database, an API key for a service it calls needs secrets. Takes effect at once, without a deploy; switching the database on starts the lambda again on its next request, so its migrations run. There is no tool to switch one off: that deletes what it held, and is the owner's to do in the editor.", - new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["privateKey"] = Field("string", "The editor key."), - ["kind"] = Field("string", $"Which kind: {string.Join(" or ", DataKinds.All.Select(k => $"'{k.Id}'"))}.") - }, - ["required"] = new JsonArray("privateKey", "kind") - }), - - Tool("read_database", "Read the lambda's database", Effect.Read, - $"The lambda's database - its records, shared by every version: whether it is switched on, how full it is, and its tables and views with their columns and how many rows each holds. With table, a page of that table's rows, newest first, up to {MaxDatabaseRows} at a time. Read only: the lambda writes it, through Database.GetConnection(). Use it to see that migrations were applied and records were written. With feature, that feature's copy.", - new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["privateKey"] = Field("string", "The editor key, or the key of a demo."), - ["feature"] = Field("string", "Read this feature's copy of the database instead."), - ["table"] = Field("string", "A table or view to read the rows of; left out, the tables are listed."), - ["offset"] = Field("integer", "Rows to skip, for the next page."), - ["limit"] = Field("integer", $"Rows to read, up to {MaxDatabaseRows}. Left out, 20."), - ["order"] = Field("string", "A column to sort by; left out, the order the rows were written in."), - ["ascending"] = Field("boolean", "Oldest or smallest first, rather than newest or largest.") - }, - ["required"] = new JsonArray("privateKey") - }), - - Tool("set_secret", "Store a secret", Effect.Replace, - $"Store an API key, password or token under a name the code reads with Secret.Read(\"NAME\") - never put one in the code, a resource, the workspace, a log line or a note. The value is sealed and can never be read back, by you or the owner; only the lambda reads it, from its next call on, without a deploy. Only set a value the user gave you for this: when you do not have it, write the code with Secret.Read and tell the user to set it under Data > Secrets in the editor - read_lambda and list_secrets list what is still missing. Secrets must be switched on first (enable_data). With feature, into that feature's copy (a sandbox key to try it with). Up to {SecretVault.MaxSecrets} secrets of {SecretVault.MaxValue / 1024} KB each.", - new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["privateKey"] = Field("string", "The editor key."), - ["feature"] = Field("string", "Store it in this feature's copy of the secrets instead."), - ["name"] = Field("string", "Letters, digits and underscores, not starting with a digit - conventionally upper case: 'STRIPE_KEY'. The name of an environment variable in an exported project."), - ["value"] = Field("string", "The value, as the user gave it.") - }, - ["required"] = new JsonArray("privateKey", "name", "value") - }), - - Tool("list_secrets", "List a lambda's secrets", Effect.Read, - "The lambda's secrets by name - never their values: whether secrets are switched on, what is stored and when, which names the code reads with Secret.Read and have no value yet (missing) - what the user still has to set - and which it only uses when they are there, checked with Secret.Exists (optional). With feature, that feature's copy.", - new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["privateKey"] = Field("string", "The editor key, or the key of a demo."), - ["feature"] = Field("string", "List this feature's copy instead.") - }, - ["required"] = new JsonArray("privateKey") - }), - - Tool("delete_secret", "Delete a secret", Effect.Replace, - "Remove a secret. Code that reads it with Secret.Read fails from its next call on. Only when the user asks for it, or a secret you stored yourself is no longer used. With feature, from that feature's copy instead.", - new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["privateKey"] = Field("string", "The editor key."), - ["feature"] = Field("string", "Delete from this feature's copy instead."), - ["name"] = Field("string", "The name, as stored - names are case sensitive.") - }, - ["required"] = new JsonArray("privateKey", "name") - }), - - Tool("showcase", "Showcase a lambda", Effect.Replace, - $"List a lambda on the public showcase page, change its entry, or take it off. Not part of building: only do this when the user asks for it. With only privateKey it returns the current entry. An entry needs a title, a description and a picture (a screenshot or short GIF of the lambda in use); it is listed while the lambda is online. {ShowcaseLimits.Tone}", - new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["privateKey"] = Field("string", "The editor key."), - ["title"] = Field("string", $"What it is, in a few words - 'Pub quiz scoreboard', not 'The ultimate quiz experience'. Up to {ShowcaseLimits.MaxTitle} characters."), - ["description"] = Field("string", $"One to three plain sentences on what a visitor can do with it. Up to {ShowcaseLimits.MaxDescription} characters."), - ["image"] = Field("string", $"The picture, base64: PNG, JPEG, GIF or WebP, up to {tiers.Get().ShowcaseImageBytes / 1024 / 1024} MB. Needed for a new entry; left out, the current one is kept."), - ["remove"] = Field("boolean", "Take the lambda off the showcase instead.") - }, - ["required"] = new JsonArray("privateKey") - }), - - Tool("open_source", "Publish a lambda's source code", Effect.Replace, - $"Publish a lambda's source code at /source/{{publicKey}} for anybody to read, star and download as a .NET project, change the license it is published under, or take it down. What is published is the program and what is written about it - every version's code, resources, documentation and tests, and the history of what each changed - never its data: not the database, not the workspace, not a secret's value. Not part of building: only do this when the user asks for it, and ask which license if they did not say (MIT unless they want another). With only privateKey it returns whether it is published. Once published, everything in every version is public, older ones included: keep keys, passwords and personal data out of the files - in secrets and the database.", - new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["privateKey"] = Field("string", "The editor key."), - ["license"] = Field("string", $"The SPDX identifier of the license: {string.Join(", ", SourceLicenses.All.Select(l => l.Id))}. Left out, it stays as it is - {SourceLicenses.Default} when first published."), - ["author"] = Field("string", $"Who holds the copyright, as the license names them - the user's name or organization, only if they gave it. Left out, it stays as it is; empty, the license names 'the authors of' the lambda. Up to {SourceLicenses.MaxAuthor} characters."), - ["remove"] = Field("boolean", "Take the source down instead. Its stars are kept for when it is published again.") - }, - ["required"] = new JsonArray("privateKey") - }), - - Tool("analytics", "Read who visits a lambda", Effect.Replace, - "How people reach a lambda, as its owner sees it in the editor: visitors, pages opened and requests per day, and the pages, referring sites, countries, devices, browsers, operating systems and crawlers (search engines, AIs, link previews) behind them. Counted by day in UTC and written down every few minutes; a year is kept, of which a free lambda shows the last 7 days and a premium one all. Off until switched on - nothing is counted before. enable: true switches it on, only when the user asks; switching off deletes what was counted and is the owner's to do in the editor. Use it to answer what the user asks about their visitors, or to see which pages matter before changing them.", - new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["privateKey"] = Field("string", "The editor key."), - ["days"] = Field("integer", "How many days, today included. Left out, as many as the lambda's tier shows, which is also the most."), - ["enable"] = Field("boolean", "Switch analytics on, counting from the next request - only when the user asked for it.") - }, - ["required"] = new JsonArray("privateKey") - }), - - Tool("list_demos", "List the demos", Effect.Read, - "Demos this platform keeps online, each a finished lambda showing one way to build something: a REST API over records, registration and login, a websocket game, uploads, live updates. Their keys are public and read only: read the closest one with read_lambda (and list_files, read_logs) before writing similar code. create_lambda with a demo's id as template starts from a copy.", - new JsonObject { ["type"] = "object", ["properties"] = new JsonObject() }), - - Tool("platform_guide", "Read the platform guide", Effect.Read, - "How this platform works - read it first: what a version, a feature and data are and how long each lives, what is code and what is a resource, the documentation and tests every version keeps, what the snippet returns, what is imported, what is refused, limits and terms.", - new JsonObject { ["type"] = "object", ["properties"] = new JsonObject() }) - ]; + return Demos(); + } + + [Tool("platform_guide", Title = "Read the platform guide", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false)] + [Summary("How this platform works - read it first: what a version, a feature and data are and how long each lives, what is code and what is a resource, the documentation and tests every version keeps, what the snippet returns, what is imported, what is refused, limits and terms.")] + public object PlatformGuide(IRequest request) + { + logger.LogInformation("Read platform guide"); + + return Guide(RequestOrigin.Of(request)); + } #endregion - #region Dispatch + #region Answers /// - /// Runs a tool and describes what happened. + /// Runs a tool, and answers a refusal of the platform as what it is. /// - /// Scheme and host the caller reached this server at, to build absolute links with - public async ValueTask CallAsync(string name, JsonObject arguments, string origin = "") + /// + /// The platform refusing something - a key nobody has, a demo that cannot + /// be changed, a name that is taken - is an answer, not a fault: the model + /// should read why and try something else rather than see a tool that + /// failed unexpectedly, which is what the MCP module makes of every + /// exception but its own , logged as an + /// error. Anything else is left to it: that is a fault, and logged as one. + /// + private static object Refusing(Func tool) { try { - return name switch - { - "create_lambda" => Create(arguments, origin), - "update_lambda" => Update(arguments, origin), - "write_code" => await WriteAsync(arguments, origin), - "change_code" => await ChangeAsync(arguments, origin), - "create_feature" => await CreateFeatureAsync(arguments, origin), - "update_feature" => UpdateFeature(arguments, origin), - "merge_feature" => await MergeFeatureAsync(arguments, origin), - "delete_feature" => DeleteFeature(arguments), - "check_code" => await CheckAsync(arguments), - "deploy" => await DeployAsync(arguments, origin), - "read_lambda" => Read(arguments, origin), - "read_logs" => Logs(arguments), - "upload_file" => await UploadAsync(arguments), - "list_files" => Files(arguments), - "delete_file" => Remove(arguments), - "enable_data" => EnableData(arguments), - "read_database" => await DatabaseAsync(arguments), - "set_secret" => SetSecret(arguments), - "list_secrets" => Secrets(arguments), - "delete_secret" => DeleteSecret(arguments), - "showcase" => Showcase(arguments, origin), - "open_source" => OpenSource(arguments, origin), - "analytics" => Analytics(arguments), - "list_demos" => ListDemos(), - "platform_guide" => ReadGuide(origin), - _ => McpProtocol.Refuse($"There is no tool called '{name}'.") - }; + return tool(); } catch (LambdaException e) { - // the platform refusing something is an answer, not a fault: the - // model should read why and try again rather than see a protocol - // error and give up - return McpProtocol.Refuse(e.Message); + return ToolResult.Error(e.Message); } - catch (Exception e) + } + + /// + private static async ValueTask RefusingAsync(Func> tool) + { + try + { + return await tool(); + } + catch (LambdaException e) { - return McpProtocol.Refuse($"Failed: {e.Message}"); + return ToolResult.Error(e.Message); } } - private JsonObject Create(JsonObject arguments, string origin) + /// + /// An answer that says the tool did not do what it was asked, with what + /// the agent needs to know to try again: the diagnostics, what is online. + /// + /// + /// Read as text by the model and as data by anything that would rather + /// parse it, like every other answer. + /// + private static ToolResult Failed(object answer) => new(null, answer, isError: true); + + /// + /// The same answer with one more thing said. + /// + /// + /// For what is worth saying however the call went on - a save that left + /// something out - so the several answers it may end in need not each + /// carry it. Written as the module writes every answer: camel case, + /// without what is null, enumerations by their names. + /// + /// Nothing to leave the answer as it is + private static object Adding(object said, string name, string? note) { - if (Flag(arguments, "acceptTerms") != true) + if (note == null) + { + return said; + } + + if (said is ToolResult { Structured: { } data } result) + { + return new ToolResult(result.Content, Adding(data, name, note), result.IsError); + } + + if (said is ToolResult) { - return McpProtocol.Refuse( - "acceptTerms must be true. Show the user the terms from platform_guide first."); + return said; } - var view = EditorViews.Parse(Text(arguments, "view")) ?? EditorView.Full; + var answer = JsonSerializer.SerializeToNode(said, AnswerFormat)!.AsObject(); + + answer[name] = note; + + return answer; + } + + private static readonly JsonSerializerOptions AnswerFormat = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new JsonStringEnumConverter() } + }; + + #endregion + + #region Code + + private object Create(bool acceptTerms, string? publicKey, string? template, EditorView? view, string origin) + { + if (!acceptTerms) + { + return ToolResult.Error("acceptTerms must be true. Show the user the terms from platform_guide first."); + } - var lambda = meta.Create(Text(arguments, "publicKey"), Text(arguments, "template"), view); + var lambda = meta.Create(publicKey, template, view ?? EditorView.Full); logger.LogInformation("Created lambda {Lambda} template {Template} view {View}", lambda.PublicKey, - Text(arguments, "template") ?? "(none)", lambda.View); + template ?? "(none)", lambda.View); - return McpProtocol.Say(new + return new { ok = true, publicKey = lambda.PublicKey, @@ -578,48 +432,42 @@ private JsonObject Create(JsonObject arguments, string origin) view = lambda.View, next = "write_code with deploy: true, and fix what needs fixing with change_code and deploy: true - a new lambda needs no feature. Send docs/product.md, docs/decisions.md and tests/README.md with the code - short for a small app (platform_guide, documentationAndTests). Once people use it, make further changes in a feature (create_feature).", warning = "The editor key cannot be recovered. Give it to the user." - }); + }; } - private JsonObject Update(JsonObject arguments, string origin) + private object Update(string privateKey, EditorView view, string origin) { - var privateKey = Required(arguments, "privateKey"); - - var view = EditorViews.Parse(Required(arguments, "view"))!.Value; - var lambda = meta.ChangeView(privateKey, view); logger.LogInformation("Set view of lambda {Lambda} to {View}", lambda.PublicKey, lambda.View); - return McpProtocol.Say(new + return new { ok = true, publicKey = lambda.PublicKey, editorUrl = $"{origin}/editor/{lambda.PrivateKey}", view = lambda.View, note = "The editor opens in this view for everybody who has not switched it for themselves; whoever has keeps their own." - }); + }; } - private async ValueTask WriteAsync(JsonObject arguments, string origin) + private async ValueTask WriteAsync(string privateKey, List sent, SaveRequest save, string origin) { - var files = Files(arguments, out var complaint); + var files = Files(sent); - if (files == null) + if (LambdaSource.Validate(files) is { } invalid) { - return McpProtocol.Refuse(complaint!); + return ToolResult.Error(invalid); } - var privateKey = Required(arguments, "privateKey"); - // what it held before the save replaces it - var had = Text(arguments, "feature") is { } feature + var had = save.Feature is { } feature ? LambdaSource.Parse(features.Get(privateKey, feature).Code) : meta.Require(privateKey).LatestVersion != null ? Api.VersionResource.Latest(meta, privateKey) : []; - var saved = McpProtocol.Adding(await SaveAsync(arguments, files, origin), "dropped", Dropped(had, files)); + var saved = Adding(await SaveAsync(privateKey, files, save, origin), "dropped", Dropped(had, files)); - return McpProtocol.Adding(saved, "misplaced", Misplaced(files)); + return Adding(saved, "misplaced", Misplaced(files)); } /// @@ -649,22 +497,12 @@ private async ValueTask WriteAsync(JsonObject arguments, string orig + "If that was not meant, save them again, or make changes with change_code, which keeps every file it is not told about."; } - private async ValueTask ChangeAsync(JsonObject arguments, string origin) + private async ValueTask ChangeAsync(string privateKey, List? sent, List? remove, List? replacements, SaveRequest save, + string origin) { - var privateKey = Required(arguments, "privateKey"); + var changed = sent != null ? Files(sent) : []; - List? changed = []; - - if (arguments.ContainsKey("files") && (changed = ParseFiles(arguments, out var complaint)) == null) - { - return McpProtocol.Refuse(complaint!); - } - - var remove = (arguments["remove"] as JsonArray)?.Select(n => n?.GetValue() ?? "").ToList(); - - var edits = (arguments["edits"] as JsonArray)?.OfType() - .Select(e => new FileEdit(Text(e, "file") ?? "", Text(e, "find") ?? "", Text(e, "replace") ?? "")) - .ToList(); + var edits = replacements?.Select(e => new FileEdit(e.File, e.Find, e.Replace)).ToList(); // a feature is changed from what it holds - and only if that is still // what it holds when the change is saved; a new version from the newest @@ -672,7 +510,7 @@ private async ValueTask ChangeAsync(JsonObject arguments, string ori int? read = null; - if (Text(arguments, "feature") is { } feature) + if (save.Feature is { } feature) { var content = features.Get(privateKey, feature); @@ -689,10 +527,10 @@ private async ValueTask ChangeAsync(JsonObject arguments, string ori if (LambdaSource.Validate(files) is { } invalid) { - return McpProtocol.Refuse(invalid); + return ToolResult.Error(invalid); } - return McpProtocol.Adding(await SaveAsync(arguments, files, origin, read), "misplaced", Misplaced(files)); + return Adding(await SaveAsync(privateKey, files, save, origin, read), "misplaced", Misplaced(files)); } /// @@ -714,11 +552,9 @@ private async ValueTask ChangeAsync(JsonObject arguments, string ori /// Stores the files - as a new version, or into the feature named - and /// deploys or checks them if the arguments ask for that. /// - private async ValueTask SaveAsync(JsonObject arguments, IReadOnlyList files, string origin, int? read = null) + private async ValueTask SaveAsync(string privateKey, IReadOnlyList files, SaveRequest save, string origin, int? read = null) { - var privateKey = Required(arguments, "privateKey"); - - var note = new VersionNote(Text(arguments, "specification"), Text(arguments, "change"), VersionOrigins.Agent); + var note = new VersionNote(save.Specification, save.Change, VersionOrigins.Agent); var code = LambdaSource.Serialize(files); @@ -726,9 +562,9 @@ private async ValueTask SaveAsync(JsonObject arguments, IReadOnlyLis // refusal - like the note below: the code matters more var documentation = Unwritten(files); - if (Text(arguments, "feature") is { } feature) + if (save.Feature is { } feature) { - return await SaveFeatureAsync(arguments, privateKey, feature, code, files.Count, note, origin, read, documentation); + return await SaveFeatureAsync(save, privateKey, feature, code, files.Count, note, origin, read, documentation); } var version = meta.Save(privateKey, code, note); @@ -741,18 +577,18 @@ private async ValueTask SaveAsync(JsonObject arguments, IReadOnlyLis ? "Pass change (one line on what the version does) and specification (what the user wants, and why) next time; the owner reads them in the version history." : null; - if (Flag(arguments, "deploy") != true) + if (!save.Deploy) { - if (Flag(arguments, "check") != true) + if (!save.Check) { - return McpProtocol.Say(new + return new { ok = true, version = version.Version, next = "deploy", note = reminder, documentation - }); + }; } /* @@ -763,7 +599,7 @@ private async ValueTask SaveAsync(JsonObject arguments, IReadOnlyLis */ var outcome = await meta.CheckAsync(privateKey, code); - return McpProtocol.Say(new + return new { ok = true, version = version.Version, @@ -772,7 +608,7 @@ private async ValueTask SaveAsync(JsonObject arguments, IReadOnlyLis next = outcome.Success ? "deploy, when it should go online" : "fix the diagnostics with change_code", note = reminder, documentation - }); + }; } return await DeployVersionAsync(privateKey, version.Version, origin, reminder, documentation); @@ -782,23 +618,23 @@ private async ValueTask SaveAsync(JsonObject arguments, IReadOnlyLis /// Stores the files as the feature's, and puts its preview online or /// compiles it if the arguments ask for that. /// - private async ValueTask SaveFeatureAsync(JsonObject arguments, string privateKey, string feature, string code, int files, VersionNote note, - string origin, int? read, string? documentation) + private async ValueTask SaveFeatureAsync(SaveRequest save, string privateKey, string feature, string code, int files, VersionNote note, + string origin, int? read, string? documentation) { var saved = features.Save(privateKey, feature, code, note, read); logger.LogInformation("Saved feature '{Feature}' of lambda {Lambda} files {Files}", saved.Name, meta.PublicKeyOf(privateKey), files); - if (Flag(arguments, "deploy") == true) + if (save.Deploy) { return await DeployFeatureAsync(privateKey, feature, origin); } - if (Flag(arguments, "check") == true) + if (save.Check) { var outcome = await meta.CheckAsync(privateKey, code); - return McpProtocol.Say(new + return new { ok = true, feature = Brief(saved, origin), @@ -808,89 +644,78 @@ private async ValueTask SaveFeatureAsync(JsonObject arguments, strin ? $"deploy with feature: '{saved.Key}' to try it at its preview address" : $"fix the diagnostics with change_code and feature: '{saved.Key}'", documentation - }); + }; } - return McpProtocol.Say(new + return new { ok = true, feature = Brief(saved, origin), next = $"Saved into the feature; nothing is online yet. Try it with deploy and feature: '{saved.Key}' (or deploy: true on the next save), then merge_feature when it does what was asked.", documentation - }); + }; } - private async ValueTask CheckAsync(JsonObject arguments) + private async ValueTask CheckAsync(string privateKey, List sent) { - var files = Files(arguments, out var complaint); + var files = Files(sent); - if (files == null) + if (LambdaSource.Validate(files) is { } invalid) { - return McpProtocol.Refuse(complaint!); + return ToolResult.Error(invalid); } - var privateKey = Required(arguments, "privateKey"); - var outcome = await meta.CheckAsync(privateKey, LambdaSource.Serialize(files)); logger.LogInformation("Checked code of lambda {Lambda} files {Files} compiles {Compiles}", meta.PublicKeyOf(privateKey), files.Count, outcome.Success); - return McpProtocol.Say(new + return new { ok = outcome.Success, compiles = outcome.Success, diagnostics = Diagnostics(outcome.Diagnostics) - }); + }; } #endregion #region Features - private async ValueTask CreateFeatureAsync(JsonObject arguments, string origin) + private async ValueTask CreateFeatureAsync(string privateKey, string name, string? specification, int? @base, string origin) { - var privateKey = Required(arguments, "privateKey"); - - var created = await features.CreateAsync(privateKey, new FeatureDraft(Text(arguments, "name"), Text(arguments, "specification"), - Number(arguments, "base"), VersionOrigins.Agent)); + var created = await features.CreateAsync(privateKey, new FeatureDraft(name, specification, @base, VersionOrigins.Agent)); logger.LogInformation("Created feature '{Feature}' of lambda {Lambda} base {Version}", created.Name, meta.PublicKeyOf(privateKey), created.Base); - return McpProtocol.Say(new + return new { ok = true, feature = Brief(created, origin), next = $"Change it with change_code (or write_code) and feature: '{created.Key}' - it holds the files of version {created.Base} and a copy of the lambda's data. deploy: true on a save puts it online at previewUrl, never at the lambda's address; read_logs with feature shows how it answers. merge_feature when it does what was asked." - }); + }; } - private JsonObject UpdateFeature(JsonObject arguments, string origin) + private object ChangeFeature(string privateKey, string feature, FeatureUpdate update, string origin) { - var privateKey = Required(arguments, "privateKey"); - - var updated = features.Update(privateKey, Required(arguments, "feature"), - new FeatureUpdate(Text(arguments, "name"), Text(arguments, "specification"), Text(arguments, "change"), - Number(arguments, "base"))); + var updated = features.Update(privateKey, feature, update); logger.LogInformation("Updated feature '{Feature}' of lambda {Lambda} base {Version}", updated.Name, meta.PublicKeyOf(privateKey), updated.Base); - return McpProtocol.Say(new + return new { ok = true, feature = Brief(updated, origin), next = updated.Mergeable ? $"It is based on the newest version, so merge_feature can merge it once it does what was asked." : $"It is based on version {updated.Base}, the newest is {updated.Newest}: bring the newer versions' changes in and move base to {updated.Newest} before merging." - }); + }; } - private async ValueTask MergeFeatureAsync(JsonObject arguments, string origin) + private async ValueTask MergeFeatureAsync(string privateKey, string feature, string? specification, string? change, bool deploy) { - var privateKey = Required(arguments, "privateKey"); + var note = new VersionNote(specification, change, VersionOrigins.Agent); - var note = new VersionNote(Text(arguments, "specification"), Text(arguments, "change"), VersionOrigins.Agent); - - var merged = await features.MergeAsync(privateKey, Required(arguments, "feature"), note, Flag(arguments, "deploy") == true); + var merged = await features.MergeAsync(privateKey, feature, note, deploy); var publicKey = meta.PublicKeyOf(privateKey); @@ -910,32 +735,32 @@ private async ValueTask MergeFeatureAsync(JsonObject arguments, stri if (!merged.Merged) { - return McpProtocol.Say(new + return Failed(new { ok = false, problem = "Not merged: the feature's code does not compile. Nothing changed - fix it with change_code and feature, then merge again.", diagnostics = Diagnostics(merged.Diagnostics) - }, failed: true); + }); } var version = merged.Version!; if (merged.Deployment is not { } deployment) { - return McpProtocol.Say(new + return new { ok = true, merged = true, version = version.Version, next = $"The feature is now version {version.Version}, and gone - its preview and its copy of the data with it; the lambda's own data is as it was. deploy puts version {version.Version} online when the user wants it live." - }); + }; } if (!deployment.Success) { var current = meta.Get(privateKey); - return McpProtocol.Say(new + return Failed(new { ok = false, merged = true, @@ -943,12 +768,12 @@ private async ValueTask MergeFeatureAsync(JsonObject arguments, stri version = version.Version, stillOnline = current?.ActiveVersion, diagnostics = Diagnostics(deployment.Diagnostics) - }, failed: true); + }); } var lambda = deployment.Lambda!; - return McpProtocol.Say(new + return new { ok = true, merged = true, @@ -959,20 +784,16 @@ private async ValueTask MergeFeatureAsync(JsonObject arguments, stri online = true, onlineUntil = lambda.DeployedUntil, next = "Merged and online. Call the public address, then read_logs to see how it answers." - }); + }; } - private JsonObject DeleteFeature(JsonObject arguments) + private object RemoveFeature(string privateKey, string feature) { - var feature = Required(arguments, "feature"); - - var privateKey = Required(arguments, "privateKey"); - var name = features.Delete(privateKey, feature); logger.LogInformation("Deleted feature '{Feature}' of lambda {Lambda}", name, meta.PublicKeyOf(privateKey)); - return McpProtocol.Say(new { ok = true, deleted = feature.Trim().ToLowerInvariant() }); + return new { ok = true, deleted = feature.Trim().ToLowerInvariant() }; } /// @@ -1066,19 +887,13 @@ private static bool LinksTo(string code, string publicKey, string host) /// putting a model or a dataset beside the code something it could be told /// about and not do. /// - private async ValueTask UploadAsync(JsonObject arguments) + private async ValueTask UploadAsync(string privateKey, string path, string content, string? named, string? encoding) { - var privateKey = Required(arguments, "privateKey"); - - var path = Required(arguments, "path"); - - var content = Required(arguments, "content"); - - var (id, feature) = WorkspaceOf(privateKey, Text(arguments, "feature"), true); + var (id, feature) = WorkspaceOf(privateKey, named, true); WorkspaceEntry written; - if (Text(arguments, "encoding") == "base64") + if (encoding == Base64) { written = await workspace.WriteEncodedAsync(id, path, content, feature); } @@ -1089,7 +904,7 @@ private async ValueTask UploadAsync(JsonObject arguments) written = await workspace.WriteAsync(id, path, stream, featureId: feature); } - if (Text(arguments, "feature") is { } named) + if (named != null) { logger.LogInformation("Wrote workspace file {Path} of feature '{Feature}' of lambda {Lambda} size {Size}", written.Path, features.NameOf(privateKey, named), meta.PublicKeyOf(privateKey), written.Size); @@ -1099,7 +914,7 @@ private async ValueTask UploadAsync(JsonObject arguments) logger.LogInformation("Wrote workspace file {Path} of lambda {Lambda} size {Size}", written.Path, meta.PublicKeyOf(privateKey), written.Size); } - return McpProtocol.Say(new + return new { ok = true, written.Path, @@ -1107,18 +922,16 @@ private async ValueTask UploadAsync(JsonObject arguments) note = feature == null ? "In the lambda's data: there at once, no deploy needed, and shared by every version." : "In the feature's copy of the data: its preview sees it at once; the lambda's own data is not touched." - }); + }; } - private JsonObject Files(JsonObject arguments) + private object Files(string privateKey, string? named) { - var privateKey = Required(arguments, "privateKey"); - - var (id, feature) = WorkspaceOf(privateKey, Text(arguments, "feature"), false); + var (id, feature) = WorkspaceOf(privateKey, named, false); var listing = workspace.List(id, feature); - if (Text(arguments, "feature") is { } named) + if (named != null) { logger.LogInformation("Listed workspace of feature '{Feature}' of lambda {Lambda}", features.NameOf(privateKey, named), meta.PublicKeyOf(privateKey)); } @@ -1127,7 +940,7 @@ private JsonObject Files(JsonObject arguments) logger.LogInformation("Listed workspace of lambda {Lambda}", meta.PublicKeyOf(privateKey)); } - return McpProtocol.Say(new + return new { enabled = listing.Enabled, files = listing.Files.Select(f => new { f.Path, f.Size, f.Modified }), @@ -1137,18 +950,16 @@ private JsonObject Files(JsonObject arguments) note = listing.Enabled ? null : "The workspace is switched off: it holds nothing, and code that uses Workspace fails. The owner switched it off, so ask the user before switching it on again with enable_data." - }); + }; } - private JsonObject EnableData(JsonObject arguments) + private object SwitchOn(string privateKey, string kind) { - var privateKey = Required(arguments, "privateKey"); - - var store = data.Enable(privateKey, Required(arguments, "kind")); + var store = data.Enable(privateKey, kind); logger.LogInformation("Enabled {Kind} of lambda {Lambda}", store.Kind, meta.PublicKeyOf(privateKey)); - return McpProtocol.Say(new + return new { ok = true, kind = store.Kind, @@ -1159,7 +970,7 @@ private JsonObject EnableData(JsonObject arguments) DataKinds.DatabaseId => "The database is on, and empty. Ship its schema as SQL migrations in resources/migrations/ (V1__Create_items.sql) and apply them with Evolve at the top of lambda.cs - see platform_guide under database, or demo-crud. The code keeps its records with a DbContext of Entity Framework Core on the connection Database.GetConnection() opens, synchronously; read_database shows what it holds.", _ => "On from the lambda's next request, without a deploy." } - }); + }; } /// @@ -1169,13 +980,9 @@ private JsonObject EnableData(JsonObject arguments) /// The answer repeats the name and never the value, so the value is in the /// conversation once - where the user put it - rather than twice. /// - private JsonObject SetSecret(JsonObject arguments) + private object StoreSecret(string privateKey, string name, string value, string? feature) { - var feature = Text(arguments, "feature"); - - var privateKey = Required(arguments, "privateKey"); - - var stored = secrets.Set(privateKey, Required(arguments, "name"), Required(arguments, "value"), feature); + var stored = secrets.Set(privateKey, name, value, feature); // the name only - the value is not repeated anywhere, here least of all if (feature != null) @@ -1187,7 +994,7 @@ private JsonObject SetSecret(JsonObject arguments) logger.LogInformation("Set secret {Name} of lambda {Lambda}", stored.Name, meta.PublicKeyOf(privateKey)); } - return McpProtocol.Say(new + return new { ok = true, name = stored.Name, @@ -1197,16 +1004,14 @@ private JsonObject SetSecret(JsonObject arguments) : "Stored in the feature's copy: its preview reads it; the lambda's own secrets are not touched. ") + (stored.Used ? "" : $"No code reads it yet - read it with Secret.Read(\"{stored.Name}\"). ") + "Do not repeat the value anywhere - not in code, notes, logs or your answer." - }); + }; } - private JsonObject Secrets(JsonObject arguments) + private object Secrets(string privateKey, string? named) { - var privateKey = Required(arguments, "privateKey"); - - var listing = secrets.List(privateKey, Text(arguments, "feature")); + var listing = secrets.List(privateKey, named); - if (Text(arguments, "feature") is { } named) + if (named != null) { logger.LogInformation("Listed secrets of feature '{Feature}' of lambda {Lambda}", features.NameOf(privateKey, named), meta.PublicKeyOf(privateKey)); } @@ -1215,7 +1020,7 @@ private JsonObject Secrets(JsonObject arguments) logger.LogInformation("Listed secrets of lambda {Lambda}", meta.PublicKeyOf(privateKey)); } - return McpProtocol.Say(new + return new { enabled = listing.Enabled, secrets = listing.Secrets.Select(s => new { s.Name, s.Changed, usedByCode = s.Used }), @@ -1227,7 +1032,7 @@ private JsonObject Secrets(JsonObject arguments) : listing.Missing.Count > 0 ? $"The code reads {string.Join(", ", listing.Missing)}, which have no value: Secret.Read throws until they are set. Ask the user to set them under Data > Secrets in the editor, or store one they gave you with set_secret." : null - }); + }; } /// @@ -1237,14 +1042,8 @@ private JsonObject Secrets(JsonObject arguments) /// Rows go out as objects keyed by column, which is what a model reads /// best; the editor gets arrays, which is what a grid draws best. /// - private async ValueTask DatabaseAsync(JsonObject arguments) + private async ValueTask DatabaseAsync(string privateKey, string? feature, string? table, int offset, int limit, string? order, bool ascending) { - var privateKey = Required(arguments, "privateKey"); - - var feature = Text(arguments, "feature"); - - var table = Text(arguments, "table"); - if (feature != null) { logger.LogInformation("Read database of feature '{Feature}' of lambda {Lambda} table {Table}", features.NameOf(privateKey, feature), meta.PublicKeyOf(privateKey), @@ -1259,7 +1058,7 @@ private async ValueTask DatabaseAsync(JsonObject arguments) { var overview = await databases.GetAsync(privateKey, feature); - return McpProtocol.Say(new + return new { enabled = overview.Enabled, overview.UsedBytes, @@ -1278,20 +1077,19 @@ private async ValueTask DatabaseAsync(JsonObject arguments) : overview.Tables.Count == 0 ? "The database is empty: no migration has made a table yet. Ship them in resources/migrations/ and apply them with Evolve as the lambda starts." : null - }); + }; } - var rows = await databases.ReadAsync(privateKey, table, Number(arguments, "offset") ?? 0, Math.Clamp(Number(arguments, "limit") ?? 20, 1, MaxDatabaseRows), - Text(arguments, "order"), Flag(arguments, "ascending") != true, feature); + var rows = await databases.ReadAsync(privateKey, table, offset, limit, order, !ascending, feature); - return McpProtocol.Say(new + return new { rows.Table, rows.Total, rows.Offset, rows = rows.Rows.Select(r => rows.Columns.Select((c, i) => (c.Name, Value: r[i])).ToDictionary(p => p.Name, p => p.Value)), next = rows.Offset + rows.Rows.Count < rows.Total ? rows.Offset + rows.Rows.Count : (int?)null - }); + }; } /// @@ -1299,14 +1097,8 @@ private async ValueTask DatabaseAsync(JsonObject arguments) /// private const int MaxDatabaseRows = 100; - private JsonObject DeleteSecret(JsonObject arguments) + private object RemoveSecret(string privateKey, string name, string? feature) { - var name = Required(arguments, "name"); - - var privateKey = Required(arguments, "privateKey"); - - var feature = Text(arguments, "feature"); - secrets.Delete(privateKey, name, feature); if (feature != null) @@ -1318,20 +1110,16 @@ private JsonObject DeleteSecret(JsonObject arguments) logger.LogInformation("Deleted secret {Name} of lambda {Lambda}", name.Trim(), meta.PublicKeyOf(privateKey)); } - return McpProtocol.Say(new { ok = true, deleted = name.Trim() }); + return new { ok = true, deleted = name.Trim() }; } - private JsonObject Remove(JsonObject arguments) + private object Remove(string privateKey, string path, string? named) { - var privateKey = Required(arguments, "privateKey"); - - var (id, feature) = WorkspaceOf(privateKey, Text(arguments, "feature"), true); - - var path = Required(arguments, "path"); + var (id, feature) = WorkspaceOf(privateKey, named, true); workspace.Delete(id, path, feature); - if (Text(arguments, "feature") is { } named) + if (named != null) { logger.LogInformation("Deleted workspace path {Path} of feature '{Feature}' of lambda {Lambda}", path, features.NameOf(privateKey, named), meta.PublicKeyOf(privateKey)); } @@ -1340,7 +1128,7 @@ private JsonObject Remove(JsonObject arguments) logger.LogInformation("Deleted workspace path {Path} of lambda {Lambda}", path, meta.PublicKeyOf(privateKey)); } - return McpProtocol.Say(new { ok = true, path }); + return new { ok = true, path }; } /// @@ -1367,12 +1155,7 @@ private JsonObject Remove(JsonObject arguments) #region Deploying - private ValueTask DeployAsync(JsonObject arguments, string origin) - => Text(arguments, "feature") is { } feature - ? DeployFeatureAsync(Required(arguments, "privateKey"), feature, origin) - : DeployVersionAsync(Required(arguments, "privateKey"), Number(arguments, "version"), origin); - - private async ValueTask DeployVersionAsync(string privateKey, int? version, string origin, string? reminder = null, string? documentation = null) + private async ValueTask DeployVersionAsync(string privateKey, int? version, string origin, string? reminder = null, string? documentation = null) { var result = await meta.DeployAsync(privateKey, version, VersionOrigins.Agent); @@ -1390,7 +1173,7 @@ private async ValueTask DeployVersionAsync(string privateKey, int? v */ var current = meta.Get(privateKey); - return McpProtocol.Say(new + return Failed(new { ok = false, problem = "Not deployed. The diagnostics say whether compiling or building the handler failed. Whatever was online before still is.", @@ -1398,7 +1181,7 @@ private async ValueTask DeployVersionAsync(string privateKey, int? v stillOnline = current?.ActiveVersion, diagnostics = Diagnostics(result.Diagnostics), next = "Fix the diagnostics with change_code and deploy: true. If people already use the lambda, fix it in a feature (create_feature) instead, so every attempt is tried at its own address rather than saved as a version." - }, failed: true); + }); } var lambda = result.Lambda!; @@ -1410,7 +1193,7 @@ private async ValueTask DeployVersionAsync(string privateKey, int? v * agent's runner read it as one, and told the owner of a premium * lambda that a change it had just put online had not gone online. */ - return McpProtocol.Say(new + return new { ok = true, publicKey = lambda.PublicKey, @@ -1422,10 +1205,10 @@ private async ValueTask DeployVersionAsync(string privateKey, int? v next = "Call the public address, then read_logs to see how it answered. While you are still building it, fix with change_code and deploy: true; once people use it, make changes in a feature (create_feature), which is tried at an address of its own and merged into the next version once it is right.", note = reminder ?? (lambda.DeployedUntil != null ? "Deploying again extends onlineUntil." : null), documentation - }); + }; } - private async ValueTask DeployFeatureAsync(string privateKey, string feature, string origin) + private async ValueTask DeployFeatureAsync(string privateKey, string feature, string origin) { var result = await features.DeployAsync(privateKey, feature); @@ -1433,14 +1216,14 @@ private async ValueTask DeployFeatureAsync(string privateKey, string if (!result.Success) { - return McpProtocol.Say(new + return Failed(new { ok = false, problem = "The preview was not deployed. The diagnostics say whether compiling or building the handler failed. Whatever the preview served before, it still does; the lambda was not touched.", feature = Brief(result.Feature, origin), diagnostics = Diagnostics(result.Diagnostics), next = $"Fix the diagnostics with change_code and feature: '{result.Feature.Key}', deploy: true." - }, failed: true); + }); } // links that leave the preview for the live lambda, said while there is still time @@ -1450,7 +1233,7 @@ private async ValueTask DeployFeatureAsync(string privateKey, string var warning = lambda != null ? Leaks(files, lambda.PublicKey) : null; - return McpProtocol.Say(new + return new { ok = true, feature = Brief(result.Feature, origin), @@ -1460,7 +1243,7 @@ private async ValueTask DeployFeatureAsync(string privateKey, string ? $"Call previewUrl, and read_logs with feature: '{result.Feature.Key}' to see how it answered. It works on its own copy of the data. Run what tests/README.md describes against previewUrl, and bring docs/ and tests/ up to date with what the feature changes. When it does what was asked: merge_feature (deploy: true to put it live)." : $"Call previewUrl, and read_logs with feature to see how it answered. Before merging: {Later(result.Feature)} saved after this feature's base, version {result.Feature.Base} - bring those changes in and move the base to {result.Feature.Newest} with update_feature.", documentation = Unwritten(files) - }); + }; } #endregion @@ -1477,12 +1260,8 @@ private async ValueTask DeployFeatureAsync(string privateKey, string /// owner gets in the control center, with the same thing left out: who /// the visitors were. /// - private JsonObject Logs(JsonObject arguments) + private object Logs(string privateKey, string? feature, string level, long since, int limit) { - var privateKey = Required(arguments, "privateKey"); - - var feature = Text(arguments, "feature"); - var id = meta.GetId(privateKey) ?? throw LambdaException.NotFound("There is no lambda with that editor key."); @@ -1504,12 +1283,7 @@ private JsonObject Logs(JsonObject arguments) logger.LogInformation("Read logs of lambda {Lambda}", meta.PublicKeyOf(privateKey)); } - var since = arguments.TryGetPropertyValue("since", out var cursor) && cursor is JsonValue value && value.TryGetValue(out var from) - ? from - : 0; - - var (said, next, missed) = book.Read(since, null, Level(Text(arguments, "level")), - Math.Clamp(Number(arguments, "limit") ?? 100, 1, 1000), lambdaId: id, feature: lines); + var (said, next, missed) = book.Read(since, null, Level(level), limit, lambdaId: id, feature: lines); var shown = said.Select(l => new { l.At, l.Level, l.Source, l.Text, detail = l.Detail, repeats = l.Repeats > 1 ? l.Repeats : (int?)null }); @@ -1522,7 +1296,7 @@ private JsonObject Logs(JsonObject arguments) if (feature != null) { // a preview is not counted, so there is no traffic to report - return McpProtocol.Say(new + return new { ok = true, feature, @@ -1531,12 +1305,12 @@ private JsonObject Logs(JsonObject arguments) missed, capturingOutput = options.CaptureLambdaOutput, note - }); + }; } var traffic = telemetry.DescribeLambda(id); - return McpProtocol.Say(new + return new { ok = true, online = lambda?.ActiveVersion != null, @@ -1552,21 +1326,23 @@ private JsonObject Logs(JsonObject arguments) missed, capturingOutput = options.CaptureLambdaOutput, note - }); + }; } - private static Microsoft.Extensions.Logging.LogLevel Level(string? level) => level?.Trim().ToLowerInvariant() switch + /// + /// The lowest level read_logs reads, by one of the names its schema allows - + /// warning and debug among them, since agents send those as well. + /// + private static LogLevel Level(string level) => level switch { - "debug" => Microsoft.Extensions.Logging.LogLevel.Debug, - "warn" or "warning" => Microsoft.Extensions.Logging.LogLevel.Warning, - "error" => Microsoft.Extensions.Logging.LogLevel.Error, - _ => Microsoft.Extensions.Logging.LogLevel.Information + "debug" => LogLevel.Debug, + "warn" or "warning" => LogLevel.Warning, + "error" => LogLevel.Error, + _ => LogLevel.Information }; - private JsonObject Read(JsonObject arguments, string origin) + private object Read(string privateKey, int? asked, string? wanted, string? only, string origin) { - var privateKey = Required(arguments, "privateKey"); - var lambda = meta.Get(privateKey) ?? throw LambdaException.NotFound("There is no lambda with that editor key."); @@ -1574,8 +1350,6 @@ private JsonObject Read(JsonObject arguments, string origin) var open = features.List(privateKey); - var wanted = Text(arguments, "feature"); - // read for a feature, the data it works on is its own copy var stores = data.List(privateKey, wanted); @@ -1598,7 +1372,7 @@ private JsonObject Read(JsonObject arguments, string origin) } else { - version = Number(arguments, "version") ?? lambda.LatestVersion; + version = asked ?? lambda.LatestVersion; if (version is { } which) { @@ -1624,8 +1398,6 @@ private JsonObject Read(JsonObject arguments, string origin) ? $"GET /api/v1/lambdas/{{privateKey}}/features/{feature.Feature.Key}/zip" : $"GET /api/v1/lambdas/{{privateKey}}/versions/{version}/zip"; - var only = Text(arguments, "file"); - IEnumerable listing; string? note = null; @@ -1683,7 +1455,7 @@ private JsonObject Read(JsonObject arguments, string origin) }; } - return McpProtocol.Say(new + return new { ok = true, lambda.PublicKey, @@ -1753,7 +1525,7 @@ private JsonObject Read(JsonObject arguments, string origin) files = listing, filesOmitted = note != null ? true : (bool?)null, note - }); + }; } /// @@ -1930,23 +1702,17 @@ private static (IEnumerable Listing, string? Note) Listing(IReadOnlyList /// read by every agent on every conversation whether it showcases /// anything or not. /// - private JsonObject Showcase(JsonObject arguments, string origin) + private object ChangeShowcase(string privateKey, string? title, string? description, string? encoded, bool remove, string origin) { - var privateKey = Required(arguments, "privateKey"); - - if (Flag(arguments, "remove") == true) + if (remove) { showcases.Remove(privateKey); logger.LogInformation("Removed lambda {Lambda} from showcase", meta.PublicKeyOf(privateKey)); - return McpProtocol.Say(new { ok = true, showcased = false }); + return new { ok = true, showcased = false }; } - var title = Text(arguments, "title"); - var description = Text(arguments, "description"); - var encoded = Text(arguments, "image"); - ShowcaseInfo? entry; if (title == null && description == null && encoded == null) @@ -1967,7 +1733,7 @@ private JsonObject Showcase(JsonObject arguments, string origin) } catch (FormatException) { - return McpProtocol.Refuse("The image is not valid base64."); + return ToolResult.Error("The image is not valid base64."); } } @@ -1981,15 +1747,15 @@ private JsonObject Showcase(JsonObject arguments, string origin) if (entry == null) { - return McpProtocol.Say(new + return new { ok = true, showcased = false, note = "Not in the showcase. Pass title, description and image to add it - only if the user asked for that." - }); + }; } - return McpProtocol.Say(new + return new { ok = true, showcased = true, @@ -1998,25 +1764,17 @@ private JsonObject Showcase(JsonObject arguments, string origin) entry.Online, page = $"{origin}/showcase", note = entry.Online ? null : "Listed once the lambda is online again - deploy it." - }); + }; } /// - /// Reads, publishes or takes down the source of a lambda. + /// Reads what was counted of a lambda's visitors, and switches counting on. /// - /// - /// One tool, like the showcase, for the same reason: it is used rarely, - /// and every tool listed is read by every agent in every conversation. - /// - private JsonObject Analytics(JsonObject arguments) + private object ReadAnalytics(string privateKey, int? days, bool enable) { - var privateKey = Required(arguments, "privateKey"); - - var days = Number(arguments, "days"); - LambdaAnalytics found; - if (Flag(arguments, "enable") == true) + if (enable) { found = analytics.Enable(privateKey); @@ -2036,50 +1794,52 @@ private JsonObject Analytics(JsonObject arguments) if (found.Since == null) { - return McpProtocol.Say(new + return new { ok = true, enabled = false, note = "Analytics are off for this lambda, so nothing about its visitors was counted. The owner switches them on in the editor (Analytics); pass enable: true only when the user asks for it." - }); + }; } var minutes = Math.Max(1, (int)found.Interval.TotalMinutes); - return McpProtocol.Say(new + return new { ok = true, analytics = AnalyticsResponse.Of(found), note = found.Shown < found.Kept && found.Earliest is { } earliest && earliest < found.From ? $"Counted by day in UTC, written down every {minutes} minutes. More is kept than this tier shows: the owner sees all of the year with premium." : $"Counted by day in UTC, written down every {minutes} minutes - the visits of the last minutes are not in it yet. Views are pages opened, by people and programs alike - botViews are those of programs, so people opened views - botViews; hits are every request; visitors are people who opened a page, counted once a day." - }); + }; } - private JsonObject OpenSource(JsonObject arguments, string origin) + /// + /// Reads, publishes or takes down the source of a lambda. + /// + /// + /// One tool, like the showcase, for the same reason: it is used rarely, + /// and every tool listed is read by every agent in every conversation. + /// + private object ChangeSource(string privateKey, string? license, string? author, bool remove, string origin) { - var privateKey = Required(arguments, "privateKey"); - SourceSettings? source; - if (Flag(arguments, "remove") == true) + if (remove) { source = sources.Withdraw(privateKey); logger.LogInformation("Unpublished source of lambda {Lambda}", source?.PublicKey ?? meta.PublicKeyOf(privateKey)); - return McpProtocol.Say(new + return new { ok = true, published = false, stars = source?.Stars, note = "Taken down: the page and the downloads are gone. Whatever somebody downloaded while it was published stays theirs under the license it came with." - }); + }; } - var license = Text(arguments, "license"); - var author = arguments.TryGetPropertyValue("author", out var named) && named != null ? named.ToString() : null; - if (license == null && author == null) { source = sources.Get(privateKey); @@ -2095,19 +1855,19 @@ private JsonObject OpenSource(JsonObject arguments, string origin) if (source is not { Published: true }) { - return McpProtocol.Say(new + return new { ok = true, published = false, stars = source?.Stars, licenses = SourceLicenses.All.Select(l => new { l.Id, l.Name, kind = l.Kind.ToString() }), note = "Not published. Pass license (and author, if the user named one) to publish it - only if the user asked for that." - }); + }; } var chosen = SourceLicenses.Find(source.License)!; - return McpProtocol.Say(new + return new { ok = true, published = true, @@ -2118,17 +1878,10 @@ private JsonObject OpenSource(JsonObject arguments, string origin) source.Stars, url = $"{origin}/source/{source.PublicKey}", note = "Everything in every version is public now, older versions included - code, resources, documentation, tests and the one line each version says it changed. Never write keys, passwords or personal data into files: secrets and the database stay private." - }); + }; } - private JsonObject ListDemos() - { - logger.LogInformation("Listed demos"); - - return Demos(); - } - - private JsonObject Demos() => McpProtocol.Say(new + private object Demos() => new { ok = true, demos = DemoCatalog.All.Select(d => new @@ -2144,7 +1897,7 @@ private JsonObject Demos() => McpProtocol.Say(new }), howToRead = "read_lambda with the demo's privateKey returns every file and the version history; list_files shows what it keeps as data; read_logs shows how it answers real traffic. Open the url to use it.", readOnly = "Anything that would change a demo is refused. To build on one, create_lambda with its id as template - that gives a lambda of your own with the same files." - }); + }; /// /// The domain of its own the lambda answers at instead of its publicUrl, @@ -2162,13 +1915,6 @@ private static IEnumerable Diagnostics(IReadOnlyList /// What the link back to this platform points at. /// @@ -2179,7 +1925,7 @@ private JsonObject ReadGuide(string origin) /// private string Home(string origin) => $"{options.PublicUrl ?? origin}/"; - private JsonObject Guide(string origin) => McpProtocol.Say(new + private object Guide(string origin) => new { ok = true, preferTheApi = "If you can make HTTP requests, the REST API at https://genhttp.dev/api/v1/openapi.json does the same as these tools and costs fewer tokens, because files are sent directly. GET /api/v1/lambdas/{privateKey}/versions/{version}/zip downloads a version; a feature is downloaded from and put back to /api/v1/lambdas/{privateKey}/features/{feature}/zip (GET, PUT) - so edit locally and push as often as it takes. POST /api/v1/lambdas/{privateKey}/versions/zip saves a zip as a new version. The zip holds the files as the lambda names them: lambda.cs, the rest of the code in its folders, resources/. Zip the contents of the folder ('zip -r ../feature.zip .', not '*'), so dot files a tool keeps come along. A zip put back is read as a commit would be: what the .gitignore files of its folders leave out is left out, as git add does. With ?layout=project, a zip has the snippet as Project.cs, as a clone does - both ways. Every endpoint that saves takes ?deploy=true. Many environments cannot reach it; then use these tools.", @@ -2440,6 +2186,20 @@ private JsonObject Guide(string origin) => McpProtocol.Say(new why = "A poll is a request whether anything changed or not, and every lambda shares one server: a page asking every second, open in a hundred browsers, is a hundred requests a second for nothing - and still a second late. A push is one write when something changes, and arrives at once.", notPolling = "Loading once when the page opens, and again when the user acts - presses a button, sends a form, comes back to the tab - is not polling." }, + toolsForAgents = new + { + what = "A lambda can offer tools to AI agents itself, over the Model Context Protocol: Layout.Create().Add(\"mcp\", Tools.Create().Server(\"name\", \"1.0.0\").Instructions(\"...\").Add(new MyTools(store))) - an agent connects to its address followed by mcp. demo-crud shows it, in Tools.cs.", + tools = "A tool is a public method marked [Tool], described for the model with [Summary]: named after the method in snake case, its parameters the arguments, each described with [Summary] too. [Range], [MaxLength] and [AllowedValues] on a parameter (using System.ComponentModel.DataAnnotations) are said to the agent and checked before the method runs. [Tool(ReadOnly = true)], Destructive and Idempotent tell a client what to ask its user before a call.", + answers = "Return a record, which the agent gets as JSON, or a string. A problem the agent can act on is thrown as ProviderException, or returned as ToolResult.Error(\"why\"), and the agent reads the message; anything else thrown is a fault. A method may take the IRequest besides, which is no argument.", + design = "Name a tool for what it does and take no more than it needs - ticking a task off takes its id, not the task. Where the lambda has an API as well, have both call the same code, so the records are the same whichever way they change.", + noSession = "Each call stands alone, like a request: keep what has to last in the database." + }, + describingAnApi = new + { + what = "A webservice describes itself as OpenAPI: Layout.Create().AddService(\"items\", new Items(store)).AddOpenApi() serves openapi.json, and .AddScalar() a page to try it at scalar/. demo-crud shows it.", + words = "[Summary] and [Remarks] on a route, its parameters and the fields of the records it takes and returns say what each does, [Tag] on the class groups its routes, and [Response] names an answer the code gives that the description could not find by itself. They need no using.", + authentication = "A route behind the Authentication module says in the description which credentials it takes and how a request without them is answered, where the concern knows where they are read - a key WithHeader, WithQueryParameter or WithCookie, basic, bearer. A key read by an Extractor of your own is described with .Documentation(SecurityScheme.ApiKey(...)), using GenHTTP.Api.Content.Documentation.Security." + }, beingFound = new { when = "For a page meant to be found or shared - a website, a landing page, a shop, a portfolio, an event: anything the user wants people to come across in a search engine, in an AI agent's answer, or as a link in a chat. A tool for a few people, a page behind a login or an admin page needs a title and nothing more.", @@ -2485,6 +2245,7 @@ private JsonObject Guide(string origin) => McpProtocol.Say(new { what = "The owner can list a lambda on the public showcase page with a title, a short description and a picture. The showcase tool does it.", when = "Only when the user asks. It is not part of building or deploying.", + pictureBytes = tiers.Get().ShowcaseImageBytes, tone = ShowcaseLimits.Tone }, openSource = new @@ -2560,7 +2321,7 @@ private JsonObject Guide(string origin) => McpProtocol.Say(new retention = $"A free lambda is removed about {(int)tiers.Get().RemovedAfter.TotalDays} days after the last of either. A premium one is kept." }, terms = SystemResource.Terms - }); + }; /// /// What a lambda in the given tier may use, as the guide says it. @@ -2581,123 +2342,67 @@ private string Allowance(LambdaTier tier) #region Arguments /// - /// What the view of an editor is, wherever a tool takes one. + /// What a file's name says, wherever a tool takes files. /// private const string FileNameField = "lambda.cs and every other .cs file, in any folder, are compiled; 'resources/...' is read and served ('resources/web/app.js'); anything else is code kept with the version, never compiled or served - 'docs/...' and 'tests/...' are the documentation and the tests."; + /// + /// What the view of an editor is, wherever a tool takes one. + /// private const string ViewField = "How the editor opens: 'Full' (the default) shows every section, 'Simple' only the app, how it is doing and a box to ask for a change. Simple suits an owner who is not going to read the code - use it when the user asks for a simple editor, or says they are not a developer."; - private static JsonObject Tool(string name, string title, Effect effect, string description, JsonObject schema) => new() - { - ["name"] = name, - ["title"] = title, - ["description"] = description, - ["inputSchema"] = schema, - ["annotations"] = new JsonObject - { - ["title"] = title, - ["readOnlyHint"] = effect.ReadOnly, - ["destructiveHint"] = effect.Destructive, - ["idempotentHint"] = effect.Idempotent, - // every tool acts on this platform and nothing beyond it - ["openWorldHint"] = false - } - }; + private const string PrivateKey = "The editor key."; + + private const string PrivateKeyOrDemo = "The editor key, or the key of a demo."; + + private const string DeployField = "Also deploy what was saved: a version at the public address, a feature at its preview address."; + + private const string CheckField = "Without deploy: compile what was saved and answer with its diagnostics, putting nothing online."; /// - /// What calling a tool does to the platform, as the protocol's hints say it. + /// The licenses a source may be published under, as open_source names them. /// /// - /// Clients read these to decide what to ask the user before a call - a - /// read can go through, a deploy is worth a question. Without them every - /// tool looks like the worst case, which is what the defaults assume. + /// Written out, since a description is fixed when the tools are described; + /// a test holds it to . /// - private readonly record struct Effect(bool ReadOnly, bool Destructive, bool Idempotent) - { - /// Looks and changes nothing. - public static Effect Read => new(true, false, true); - - /// Adds something new and replaces nothing. - public static Effect Create => new(false, false, false); - - /// Adds a version or changes a feature, and with deploy: true replaces what is online. - public static Effect Save => new(false, true, false); - - /// Replaces or removes what was there, the same way however often. - public static Effect Replace => new(false, true, true); - } - - private static JsonObject Field(string type, string description) => new() - { - ["type"] = type, - ["description"] = description - }; - - private static string? Text(JsonObject arguments, string name) - => arguments.TryGetPropertyValue(name, out var value) && value is JsonValue text && text.TryGetValue(out var result) - ? result - : null; - - private static string Required(JsonObject arguments, string name) - => Text(arguments, name) ?? throw LambdaException.Invalid($"'{name}' is needed and was not given."); - - private static int? Number(JsonObject arguments, string name) - => arguments.TryGetPropertyValue(name, out var value) && value is JsonValue number && number.TryGetValue(out var result) - ? result - : null; - - private static bool? Flag(JsonObject arguments, string name) - => arguments.TryGetPropertyValue(name, out var value) && value is JsonValue flag && flag.TryGetValue(out var result) - ? result - : null; + private const string LicenseField = + "The SPDX identifier of the license: MIT, Apache-2.0, BSD-3-Clause, MPL-2.0, GPL-3.0-or-later, AGPL-3.0-or-later or Unlicense. Left out, it stays as it is - " + SourceLicenses.Default + " when first published."; /// - /// The files an argument list carries, or null and a complaint. + /// The encoding of a file that is not text. /// - private static IReadOnlyList? Files(JsonObject arguments, out string? complaint) - { - var files = ParseFiles(arguments, out complaint); - - if (files == null) - { - return null; - } - - complaint = LambdaSource.Validate(files); - - return complaint == null ? files : null; - } + private const string Base64 = "base64"; /// - /// The files an argument list carries, without checking them as a whole. + /// A file as write_code, change_code and check_code take it. /// - private static List? ParseFiles(JsonObject arguments, out string? complaint) - { - complaint = null; - - if (!arguments.TryGetPropertyValue("files", out var value) || value is not JsonArray array) - { - complaint = "'files' has to be an array of { name, code }, with lambda.cs first."; - return null; - } - - var files = new List(); + /// + /// The encoding is checked with the rest of the files, by the rule the + /// API holds them to as well (), which + /// takes what agents send for text besides leaving it out. + /// + public sealed record CodeFile([Summary(FileNameField)] string Name, + [Summary("Contents, base64 if encoding says so.")] string Code, + [Summary("'base64' for binary files; omit otherwise.")] string? Encoding = null); - foreach (var entry in array) - { - if (entry is not JsonObject file) - { - complaint = "Every file has to be an object with a name and some code."; - return null; - } + /// + /// A replacement of text within a file, as change_code takes it. + /// + public sealed record CodeEdit([Summary("The file to change.")] string File, + [Summary("Text that occurs exactly once in the file.")] string Find, + [Summary("What to put in its place.")] string Replace); - files.Add(new LambdaFile(Text(file, "name") ?? "", Text(file, "code") ?? "", Text(file, "encoding"))); - } + /// + /// What a save is asked to do besides storing the files. + /// + /// The feature to save into, or nothing for a new version + private sealed record SaveRequest(string? Feature, string? Specification, string? Change, bool Deploy, bool Check); - return files; - } + private static List Files(IEnumerable files) + => files.Select(f => new LambdaFile(f.Name, f.Code, f.Encoding)).ToList(); #endregion diff --git a/src/GenHTTP.Lambda/Api/Model/Admin.cs b/src/GenHTTP.Lambda/Api/Model/Admin.cs index 85cadcc1..7b4df7a9 100644 --- a/src/GenHTTP.Lambda/Api/Model/Admin.cs +++ b/src/GenHTTP.Lambda/Api/Model/Admin.cs @@ -1,4 +1,4 @@ -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; using GenHTTP.Lambda.Services.Telemetry; diff --git a/src/GenHTTP.Lambda/Api/Model/Analytics.cs b/src/GenHTTP.Lambda/Api/Model/Analytics.cs index af80cc4b..860c4d27 100644 --- a/src/GenHTTP.Lambda/Api/Model/Analytics.cs +++ b/src/GenHTTP.Lambda/Api/Model/Analytics.cs @@ -1,4 +1,4 @@ -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; using GenHTTP.Lambda.Services.Analytics; diff --git a/src/GenHTTP.Lambda/Api/Model/Builds.cs b/src/GenHTTP.Lambda/Api/Model/Builds.cs index d87facb9..ef19ee60 100644 --- a/src/GenHTTP.Lambda/Api/Model/Builds.cs +++ b/src/GenHTTP.Lambda/Api/Model/Builds.cs @@ -1,4 +1,4 @@ -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; namespace GenHTTP.Lambda.Api.Model; diff --git a/src/GenHTTP.Lambda/Api/Model/Code.cs b/src/GenHTTP.Lambda/Api/Model/Code.cs index 488677f5..aa6b61c9 100644 --- a/src/GenHTTP.Lambda/Api/Model/Code.cs +++ b/src/GenHTTP.Lambda/Api/Model/Code.cs @@ -1,4 +1,4 @@ -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; using GenHTTP.Lambda.Services.Deployment.Model; diff --git a/src/GenHTTP.Lambda/Api/Model/Data.cs b/src/GenHTTP.Lambda/Api/Model/Data.cs index 8b9b7bf0..2d4999d6 100644 --- a/src/GenHTTP.Lambda/Api/Model/Data.cs +++ b/src/GenHTTP.Lambda/Api/Model/Data.cs @@ -1,4 +1,4 @@ -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; namespace GenHTTP.Lambda.Api.Model; diff --git a/src/GenHTTP.Lambda/Api/Model/Demos.cs b/src/GenHTTP.Lambda/Api/Model/Demos.cs index 38596adc..0c07fd1d 100644 --- a/src/GenHTTP.Lambda/Api/Model/Demos.cs +++ b/src/GenHTTP.Lambda/Api/Model/Demos.cs @@ -1,4 +1,4 @@ -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; namespace GenHTTP.Lambda.Api.Model; diff --git a/src/GenHTTP.Lambda/Api/Model/Deployments.cs b/src/GenHTTP.Lambda/Api/Model/Deployments.cs index 98106326..fec2168f 100644 --- a/src/GenHTTP.Lambda/Api/Model/Deployments.cs +++ b/src/GenHTTP.Lambda/Api/Model/Deployments.cs @@ -1,4 +1,4 @@ -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; using GenHTTP.Lambda.Services.Deployment.Model; diff --git a/src/GenHTTP.Lambda/Api/Model/Domains.cs b/src/GenHTTP.Lambda/Api/Model/Domains.cs index 1bd9906d..76d052ca 100644 --- a/src/GenHTTP.Lambda/Api/Model/Domains.cs +++ b/src/GenHTTP.Lambda/Api/Model/Domains.cs @@ -1,4 +1,4 @@ -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; namespace GenHTTP.Lambda.Api.Model; diff --git a/src/GenHTTP.Lambda/Api/Model/Errors.cs b/src/GenHTTP.Lambda/Api/Model/Errors.cs index fcba4609..a378ac74 100644 --- a/src/GenHTTP.Lambda/Api/Model/Errors.cs +++ b/src/GenHTTP.Lambda/Api/Model/Errors.cs @@ -1,4 +1,4 @@ -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; namespace GenHTTP.Lambda.Api.Model; diff --git a/src/GenHTTP.Lambda/Api/Model/Features.cs b/src/GenHTTP.Lambda/Api/Model/Features.cs index a703ea61..3fb0cb85 100644 --- a/src/GenHTTP.Lambda/Api/Model/Features.cs +++ b/src/GenHTTP.Lambda/Api/Model/Features.cs @@ -1,4 +1,4 @@ -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; using GenHTTP.Lambda.Services.Deployment.Model; diff --git a/src/GenHTTP.Lambda/Api/Model/Files.cs b/src/GenHTTP.Lambda/Api/Model/Files.cs index 65031996..ee11c0d4 100644 --- a/src/GenHTTP.Lambda/Api/Model/Files.cs +++ b/src/GenHTTP.Lambda/Api/Model/Files.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; using GenHTTP.Lambda.Services.Deployment.Model; diff --git a/src/GenHTTP.Lambda/Api/Model/Lambdas.cs b/src/GenHTTP.Lambda/Api/Model/Lambdas.cs index 9955a373..accd5318 100644 --- a/src/GenHTTP.Lambda/Api/Model/Lambdas.cs +++ b/src/GenHTTP.Lambda/Api/Model/Lambdas.cs @@ -1,4 +1,4 @@ -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; using GenHTTP.Lambda.Data.Entities; using GenHTTP.Lambda.Services.Hosting; diff --git a/src/GenHTTP.Lambda/Api/Model/Logs.cs b/src/GenHTTP.Lambda/Api/Model/Logs.cs index dd2e0d25..0233eb4e 100644 --- a/src/GenHTTP.Lambda/Api/Model/Logs.cs +++ b/src/GenHTTP.Lambda/Api/Model/Logs.cs @@ -1,4 +1,4 @@ -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; namespace GenHTTP.Lambda.Api.Model; diff --git a/src/GenHTTP.Lambda/Api/Model/Monitoring.cs b/src/GenHTTP.Lambda/Api/Model/Monitoring.cs index 5824c2ef..ab98ad98 100644 --- a/src/GenHTTP.Lambda/Api/Model/Monitoring.cs +++ b/src/GenHTTP.Lambda/Api/Model/Monitoring.cs @@ -1,4 +1,4 @@ -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; using GenHTTP.Lambda.Services.Meta; diff --git a/src/GenHTTP.Lambda/Api/Model/Paging.cs b/src/GenHTTP.Lambda/Api/Model/Paging.cs index 8eb831dd..a13ed749 100644 --- a/src/GenHTTP.Lambda/Api/Model/Paging.cs +++ b/src/GenHTTP.Lambda/Api/Model/Paging.cs @@ -1,4 +1,4 @@ -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; namespace GenHTTP.Lambda.Api.Model; diff --git a/src/GenHTTP.Lambda/Api/Model/Platform.cs b/src/GenHTTP.Lambda/Api/Model/Platform.cs index 052e6e38..856193c5 100644 --- a/src/GenHTTP.Lambda/Api/Model/Platform.cs +++ b/src/GenHTTP.Lambda/Api/Model/Platform.cs @@ -1,4 +1,4 @@ -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; using GenHTTP.Lambda.Services.Deployment.Model; diff --git a/src/GenHTTP.Lambda/Api/Model/Showcases.cs b/src/GenHTTP.Lambda/Api/Model/Showcases.cs index af6e6543..d435db35 100644 --- a/src/GenHTTP.Lambda/Api/Model/Showcases.cs +++ b/src/GenHTTP.Lambda/Api/Model/Showcases.cs @@ -1,4 +1,4 @@ -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; using GenHTTP.Lambda.Services.Hosting; using GenHTTP.Lambda.Services.Showcase; diff --git a/src/GenHTTP.Lambda/Api/Model/Sources.cs b/src/GenHTTP.Lambda/Api/Model/Sources.cs index abbd801d..8a7618ae 100644 --- a/src/GenHTTP.Lambda/Api/Model/Sources.cs +++ b/src/GenHTTP.Lambda/Api/Model/Sources.cs @@ -1,4 +1,4 @@ -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; using GenHTTP.Lambda.Services.Hosting; using GenHTTP.Lambda.Services.Source; diff --git a/src/GenHTTP.Lambda/Api/Model/Telemetry.cs b/src/GenHTTP.Lambda/Api/Model/Telemetry.cs index 8d025abc..182979e7 100644 --- a/src/GenHTTP.Lambda/Api/Model/Telemetry.cs +++ b/src/GenHTTP.Lambda/Api/Model/Telemetry.cs @@ -1,4 +1,4 @@ -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; using GenHTTP.Lambda.Services.Meta; using GenHTTP.Lambda.Services.Telemetry; diff --git a/src/GenHTTP.Lambda/Api/Model/Versions.cs b/src/GenHTTP.Lambda/Api/Model/Versions.cs index a6780882..b2d408e1 100644 --- a/src/GenHTTP.Lambda/Api/Model/Versions.cs +++ b/src/GenHTTP.Lambda/Api/Model/Versions.cs @@ -1,4 +1,4 @@ -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; using GenHTTP.Lambda.Services.Deployment.Model; diff --git a/src/GenHTTP.Lambda/Api/MonitoringResource.cs b/src/GenHTTP.Lambda/Api/MonitoringResource.cs index 63c2d57e..4b034378 100644 --- a/src/GenHTTP.Lambda/Api/MonitoringResource.cs +++ b/src/GenHTTP.Lambda/Api/MonitoringResource.cs @@ -1,4 +1,4 @@ -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; using GenHTTP.Api.Protocol; using GenHTTP.Lambda.Api.Infrastructure; diff --git a/src/GenHTTP.Lambda/Api/SecretResource.cs b/src/GenHTTP.Lambda/Api/SecretResource.cs index 6b1285d9..c8f901c9 100644 --- a/src/GenHTTP.Lambda/Api/SecretResource.cs +++ b/src/GenHTTP.Lambda/Api/SecretResource.cs @@ -1,4 +1,4 @@ -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; using GenHTTP.Api.Protocol; using GenHTTP.Lambda.Api.Model; diff --git a/src/GenHTTP.Lambda/Api/ShowcaseResource.cs b/src/GenHTTP.Lambda/Api/ShowcaseResource.cs index 6762fd24..9db7521f 100644 --- a/src/GenHTTP.Lambda/Api/ShowcaseResource.cs +++ b/src/GenHTTP.Lambda/Api/ShowcaseResource.cs @@ -1,4 +1,4 @@ -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; using GenHTTP.Api.Protocol; using GenHTTP.Lambda.Api.Infrastructure; diff --git a/src/GenHTTP.Lambda/Api/SourceResource.cs b/src/GenHTTP.Lambda/Api/SourceResource.cs index 705923b9..6d47c688 100644 --- a/src/GenHTTP.Lambda/Api/SourceResource.cs +++ b/src/GenHTTP.Lambda/Api/SourceResource.cs @@ -1,7 +1,7 @@ using System.IO.Compression; using System.Text; -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; using GenHTTP.Api.Protocol; using GenHTTP.Lambda.Api.Infrastructure; diff --git a/src/GenHTTP.Lambda/Api/SystemResource.cs b/src/GenHTTP.Lambda/Api/SystemResource.cs index 1af3e75e..20d69425 100644 --- a/src/GenHTTP.Lambda/Api/SystemResource.cs +++ b/src/GenHTTP.Lambda/Api/SystemResource.cs @@ -1,4 +1,4 @@ -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; using GenHTTP.Lambda.Api.Infrastructure; using GenHTTP.Lambda.Api.Model; diff --git a/src/GenHTTP.Lambda/Api/TelemetryResource.cs b/src/GenHTTP.Lambda/Api/TelemetryResource.cs index a25c0c44..1a2adb32 100644 --- a/src/GenHTTP.Lambda/Api/TelemetryResource.cs +++ b/src/GenHTTP.Lambda/Api/TelemetryResource.cs @@ -1,7 +1,7 @@ using System.Runtime; using System.Runtime.InteropServices; -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; using GenHTTP.Lambda.Api.Infrastructure; using GenHTTP.Lambda.Api.Model; @@ -30,9 +30,6 @@ namespace GenHTTP.Lambda.Api; Readings of memory, the collector, the threads and the connections, taken on an interval and kept in memory, and what happened on the platform per day. Only the public key identifies a lambda here. """)] -[RequestHeader(AdminAuthentication.Header, AdminAuthentication.HeaderDescription, Required = true)] -[Response(ResponseStatus.Unauthorized, AdminAuthentication.MissingDescription)] -[Response(ResponseStatus.Forbidden, AdminAuthentication.WrongDescription)] public sealed class TelemetryResource(ITelemetryService telemetry, IMetaService meta, LambdaOptions options) { diff --git a/src/GenHTTP.Lambda/Api/VersionResource.cs b/src/GenHTTP.Lambda/Api/VersionResource.cs index 881ac01c..2c7dca75 100644 --- a/src/GenHTTP.Lambda/Api/VersionResource.cs +++ b/src/GenHTTP.Lambda/Api/VersionResource.cs @@ -1,4 +1,4 @@ -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; using GenHTTP.Api.Protocol; using GenHTTP.Lambda.Api.Infrastructure; diff --git a/src/GenHTTP.Lambda/Application.cs b/src/GenHTTP.Lambda/Application.cs index b2b6ec24..7f68d079 100644 --- a/src/GenHTTP.Lambda/Application.cs +++ b/src/GenHTTP.Lambda/Application.cs @@ -265,7 +265,7 @@ private static IHandler BuildPlatform(IServiceProvider services, LambdaOptions o var layout = Layout.Create() .Add("api", ApiLayout.Create(options)) // one path, for agents rather than for browsers - .Add("mcp", new McpHandlerBuilder(services.GetRequiredService(), options.McpOrigins)) + .Add("mcp", McpEndpoint.Create(options)) .Add("features", previews) // the repositories below the editor and the published sources, asked // before the pages that answer every other path there diff --git a/src/GenHTTP.Lambda/Program.cs b/src/GenHTTP.Lambda/Program.cs index f135ac31..ce8793ec 100644 --- a/src/GenHTTP.Lambda/Program.cs +++ b/src/GenHTTP.Lambda/Program.cs @@ -4,6 +4,7 @@ using GenHTTP.Api.Infrastructure; using GenHTTP.Lambda; +using GenHTTP.Lambda.Api.Mcp; using GenHTTP.Lambda.Configuration; using GenHTTP.Lambda.Infrastructure; using GenHTTP.Lambda.Services.Diagnostics; @@ -56,6 +57,12 @@ // request - written to the console while the request waits, and into the // book where, each with its own duration, nothing ever folded them builder.AddFilter("Microsoft.AspNetCore", LogLevel.Warning); + + // and the MCP module writes a line of its own for every tool call, beside + // the one the tool writes about what it did - and one for every refusal, + // which is an answer rather than a problem. A tool that fails is an error, + // and still written down + builder.AddFilter("GenHTTP.Modules.Mcp", LogLevel.Warning); }); var logger = loggers.CreateLogger("GenHTTP.Lambda"); @@ -69,6 +76,13 @@ logger.LogWarning("Read {Variable} from the environment, which is deprecated: the limits of the tiers are set in the administration panel, and a value saved there wins", variable); } +// an entry that names no origin is left out rather than keep the server from +// starting, and said here so the operator finds out +foreach (var entry in options.McpOrigins.Where(e => McpEndpoint.Origin(e) == null)) +{ + logger.LogWarning("Ignored {Entry} in LAMBDA_MCP_ORIGINS, which names neither a host nor an origin such as https://genhttp.dev", entry); +} + // an installation that names where it is found, and not where its lambdas // are, hands its visitors addresses below localhost if (options.PublicUrl != null && options.HostingUrl == null) diff --git a/src/GenHTTP.Lambda/Resources/Templates/DemoCrud.cs.txt b/src/GenHTTP.Lambda/Resources/Templates/DemoCrud.cs.txt index 63286d6b..0a7501cc 100644 --- a/src/GenHTTP.Lambda/Resources/Templates/DemoCrud.cs.txt +++ b/src/GenHTTP.Lambda/Resources/Templates/DemoCrud.cs.txt @@ -8,12 +8,14 @@ // // openapi.json the generated description of the API // scalar/ a page to try the API in the browser +// mcp the same tasks as tools for an AI agent // anything else the page in resources/web/, which uses the API // // The files: // // lambda.cs this one: the wiring, and bringing the database up to date // Tasks.cs the API - a class whose methods are the routes +// Tools.cs the tools - a class whose methods an agent calls // Store.cs the tasks in the database, through Entity Framework Core // resources/migrations/ the SQL that makes the table, applied once by Evolve // resources/web/ the front end, shipped with the code and served as it is @@ -48,9 +50,16 @@ if (store.Count() == 0) Tasks.Seed(store); } +// what an agent is told before it calls anything: how the tools fit together +var tools = Tools.Create() + .Server("tasks", "1.0.0", "Tasks") + .Instructions("A task list shared by everybody who opens it. list_tasks first: the other tools take the id it names.") + .Add(new TaskTools(store)); + return Layout.Create() .AddService("tasks", new Tasks(store)) .AddOpenApi() .AddScalar() + .Add("mcp", tools) // last, so that it only answers what nothing above did .Add(Resources.App("web")); diff --git a/src/GenHTTP.Lambda/Resources/Templates/DemoCrudDecisions.md.txt b/src/GenHTTP.Lambda/Resources/Templates/DemoCrudDecisions.md.txt index 997edb65..16893053 100644 --- a/src/GenHTTP.Lambda/Resources/Templates/DemoCrudDecisions.md.txt +++ b/src/GenHTTP.Lambda/Resources/Templates/DemoCrudDecisions.md.txt @@ -2,7 +2,11 @@ ## The API is a class whose methods are the routes -`Tasks.cs` is a webservice: every public method with `[ResourceMethod]` is a route. A parameter named like a segment of the path (`:id`) comes from the path, a record from the JSON body, anything else from the query, and what a method returns is sent as camel case JSON. It is how GenHTTP writes a REST API, and it gives the OpenAPI description (`AddOpenApi()`) and the page to try it (`AddScalar()`) for nothing. +`Tasks.cs` is a webservice: every public method with `[ResourceMethod]` is a route. A parameter named like a segment of the path (`:id`) comes from the path, a record from the JSON body, anything else from the query, and what a method returns is sent as camel case JSON. It is how GenHTTP writes a REST API, and it gives the OpenAPI description (`AddOpenApi()`) and the page to try it (`AddScalar()`) for nothing. `[Summary]` and `[Remarks]` on the routes, their parameters and the fields of `TaskInput` are what the description says about each. + +## Agents get tools of their own, beside the API + +`Tools.cs` offers the same tasks to an AI agent over the Model Context Protocol, at `mcp`: every public method with `[Tool]` is a tool, described by its `[Summary]`, its parameters the arguments. A REST API is made for a program someone writes against it; a tool is made for a model choosing what to call, so it is named for what it does (`add_task`, `complete_task`), says so in a sentence, and takes no more than it needs - ticking a task off takes its id, not the whole task again. Both work on the same `TaskStore` and check what they are sent with the same `Tasks.Validate`, so the list is the same whichever way it is changed. `[MaxLength]` on a parameter tells the agent the limit before it calls, and a refusal is answered as an error the agent reads and acts on. The hints say what a tool does to the list - `ReadOnly` for listing, `Destructive` for removing - so a client knows what to ask its user first. ## The tasks are a table in the database diff --git a/src/GenHTTP.Lambda/Resources/Templates/DemoCrudProduct.md.txt b/src/GenHTTP.Lambda/Resources/Templates/DemoCrudProduct.md.txt index 9b087e8a..a29e7942 100644 --- a/src/GenHTTP.Lambda/Resources/Templates/DemoCrudProduct.md.txt +++ b/src/GenHTTP.Lambda/Resources/Templates/DemoCrudProduct.md.txt @@ -1,6 +1,6 @@ # Tasks -A shared task list: anybody who opens it can add a task, tick it off, change it and remove it, and finds the list as it was left the next time. Everything the page does goes through a small REST API, which can be used on its own. +A shared task list: anybody who opens it can add a task, tick it off, change it and remove it, and finds the list as it was left the next time. Everything the page does goes through a small REST API, which can be used on its own - and an AI agent can keep the list too. This is one of the demos of GenHTTP Lambda, kept online and read only. To build on it, create a lambda of your own from it - a copy is yours to change. @@ -8,7 +8,7 @@ This is one of the demos of GenHTTP Lambda, kept online and read only. To build ## Who it is for -Anybody who keeps a short list together with others - chores, shopping, the to-dos of a small team - and does not want accounts for it. And developers who want to see a REST API beside the page that uses it. +Anybody who keeps a short list together with others - chores, shopping, the to-dos of a small team - and does not want accounts for it. And developers who want to see a REST API beside the page that uses it, and the same records offered to an AI agent. ## What people do with it @@ -17,6 +17,7 @@ Anybody who keeps a short list together with others - chores, shopping, the to-d - **Change** its title or notes, or **remove** it. - **Search** the titles and notes. - **Use the API directly**: try it at `scalar/`, or read its description at `openapi.json`, to add tasks from somewhere else. +- **Let an AI agent keep the list**: an agent such as Claude connects to `mcp` and lists, adds, ticks off and removes tasks with tools of its own. ## Features, and why they are there diff --git a/src/GenHTTP.Lambda/Resources/Templates/DemoCrudSmoke.mjs.txt b/src/GenHTTP.Lambda/Resources/Templates/DemoCrudSmoke.mjs.txt index d4b11b65..e74f379c 100644 --- a/src/GenHTTP.Lambda/Resources/Templates/DemoCrudSmoke.mjs.txt +++ b/src/GenHTTP.Lambda/Resources/Templates/DemoCrudSmoke.mjs.txt @@ -3,8 +3,8 @@ // node smoke.mjs
the preview of a feature, or a copy of your own // // Adds a task of its own, reads, searches, changes and removes it, checks -// what is refused and that the page and the API description are served - and -// removes what it added, whatever happens. Ends with 1, naming what failed, +// what is refused, that the page and the API description are served and that +// an agent is offered the tools - and removes what it added, whatever happens. Ends with 1, naming what failed, // when something did not pass. Needs Node 18 or later and nothing else. const address = process.argv[2]; @@ -86,6 +86,11 @@ try { const described = await call('GET', 'openapi.json'); check('the API is described', described.status === 200 && described.json?.paths != null, `status ${described.status}`); + + const offered = await call('POST', 'mcp', { jsonrpc: '2.0', id: 1, method: 'tools/list' }); + const tools = (offered.json?.result?.tools ?? []).map((t) => t.name); + check('an agent is offered the tools', offered.status === 200 && ['list_tasks', 'add_task', 'complete_task', 'remove_task'].every((t) => tools.includes(t)), + `status ${offered.status}`); } catch (error) { check(`${base} answers`, false, error.message); } finally { diff --git a/src/GenHTTP.Lambda/Resources/Templates/DemoCrudTasks.cs.txt b/src/GenHTTP.Lambda/Resources/Templates/DemoCrudTasks.cs.txt index e6604265..8307a89a 100644 --- a/src/GenHTTP.Lambda/Resources/Templates/DemoCrudTasks.cs.txt +++ b/src/GenHTTP.Lambda/Resources/Templates/DemoCrudTasks.cs.txt @@ -7,38 +7,43 @@ // // Problems are thrown as ProviderException with the status to answer, which // the platform turns into a response with that status and the message. +// +// [Summary] and [Remarks] say what a route does in the API's description, +// openapi.json - and so in scalar/, where it is tried. A parameter or a field +// of a record can have one too. -/// What a client sends to add or change a task. -public record TaskInput(string Title, string Notes, bool Done); +[Summary("What a client sends to add or change a task.")] +public record TaskInput([Summary("What is to be done, up to 120 characters")] string Title, + [Summary("More about it, up to 1000 characters")] string Notes, + [Summary("Whether it is done")] bool Done); public class Tasks(TaskStore store) { - private const int MaxTitle = 120; + internal const int MaxTitle = 120; - private const int MaxNotes = 1000; + internal const int MaxNotes = 1000; [ResourceMethod] - public List List(string search = null) => store.List(search); + [Summary("Lists the tasks")] + [Remarks("Open tasks first, the newest first. With search, only those whose title or notes contain it, whatever the case.")] + public List List([Summary("Text to look for")] string search = null) => store.List(search); [ResourceMethod(":id")] + [Summary("Reads a task")] public TaskItem Get(string id) => store.Find(id) ?? throw Missing(id); [ResourceMethod(Method.Post)] + [Summary("Adds a task")] public Result Create(TaskInput input) { var (title, notes) = Validate(input); - var now = DateTime.UtcNow; - - var task = new TaskItem { Id = Guid.NewGuid().ToString("n")[..10], Title = title, Notes = notes, Done = input.Done, Created = now, Updated = now }; - - store.Add(task); - // 201 rather than 200: something new exists now - return new Result(task).Status(ResponseStatus.Created); + return new Result(Add(store, title, notes, input.Done)).Status(ResponseStatus.Created); } [ResourceMethod(Method.Put, ":id")] + [Summary("Changes a task")] public TaskItem Update(string id, TaskInput input) { var (title, notes) = Validate(input); @@ -47,6 +52,7 @@ public class Tasks(TaskStore store) } [ResourceMethod(Method.Delete, ":id")] + [Summary("Removes a task")] public void Delete(string id) { // nothing returned is answered with 204 No Content @@ -56,10 +62,24 @@ public class Tasks(TaskStore store) } } + /// + /// Makes a task of what was checked, and keeps it. + /// + internal static TaskItem Add(TaskStore store, string title, string notes, bool done) + { + var now = DateTime.UtcNow; + + var task = new TaskItem { Id = Guid.NewGuid().ToString("n")[..10], Title = title, Notes = notes, Done = done, Created = now, Updated = now }; + + store.Add(task); + + return task; + } + /// /// Checks what was sent, and answers 400 with the reason where it is wrong. /// - private static (string Title, string Notes) Validate(TaskInput input) + internal static (string Title, string Notes) Validate(TaskInput input) { var title = (input?.Title ?? "").Trim(); @@ -83,7 +103,7 @@ public class Tasks(TaskStore store) return (title, notes); } - private static ProviderException Missing(string id) + internal static ProviderException Missing(string id) => new(ResponseStatus.NotFound, $"There is no task '{id}'."); /// diff --git a/src/GenHTTP.Lambda/Resources/Templates/DemoCrudTesting.md.txt b/src/GenHTTP.Lambda/Resources/Templates/DemoCrudTesting.md.txt index 45aaeaa4..faf9adca 100644 --- a/src/GenHTTP.Lambda/Resources/Templates/DemoCrudTesting.md.txt +++ b/src/GenHTTP.Lambda/Resources/Templates/DemoCrudTesting.md.txt @@ -1,6 +1,6 @@ # How it is tested -`smoke.mjs` checks the API from the outside. It adds a task of its own, reads, searches, changes and removes it, checks what is refused and that the page and the API description are served - and removes what it added, whatever happens. +`smoke.mjs` checks the API from the outside. It adds a task of its own, reads, searches, changes and removes it, checks what is refused, that the page and the API description are served and that an agent is offered the tools - and removes what it added, whatever happens. ``` node tests/smoke.mjs
@@ -23,6 +23,7 @@ The address is where the app answers: the preview of a feature (`https://…/fea | A task is removed | `DELETE tasks/{id}` | 204, and 404 when it is read again | | The page | `GET ./` | 200, HTML | | The API is described | `GET openapi.json` | 200, the OpenAPI document | +| An agent is offered the tools | `POST mcp` with `{ "jsonrpc": "2.0", "id": 1, "method": "tools/list" }` | 200, `list_tasks`, `add_task`, `complete_task` and `remove_task` | ## Test data diff --git a/src/GenHTTP.Lambda/Resources/Templates/DemoCrudTools.cs.txt b/src/GenHTTP.Lambda/Resources/Templates/DemoCrudTools.cs.txt new file mode 100644 index 00000000..957b1a41 --- /dev/null +++ b/src/GenHTTP.Lambda/Resources/Templates/DemoCrudTools.cs.txt @@ -0,0 +1,50 @@ +// The same tasks as tools for an AI agent, over the Model Context Protocol. +// +// An agent is pointed at mcp - with Claude Code: +// +// claude mcp add --transport http tasks
mcp +// +// and sees a tool for every public method with [Tool]: named after the method +// in snake case (add_task), described by its [Summary], its parameters the +// arguments. [MaxLength] on a parameter is said to the agent and checked +// before the method runs; a ProviderException is answered as an error the +// agent reads, so it can try again. ReadOnly, Destructive and Idempotent tell +// a client what to ask its user before a call. +// +// The tools work on the store the API works on, and check what they are sent +// the way the API does - two doors to the same records. +using System.ComponentModel.DataAnnotations; + +public class TaskTools(TaskStore store) +{ + + [Tool(ReadOnly = true)] + [Summary("Lists the tasks: open ones first, the newest first. With search, only those whose title or notes contain it.")] + public List ListTasks([Summary("Text to look for, whatever its case")] string search = null) => store.List(search); + + [Tool] + [Summary("Adds a task to the list everybody shares.")] + public TaskItem AddTask([Summary("What is to be done"), MaxLength(Tasks.MaxTitle)] string title, + [Summary("More about it, where there is more to say"), MaxLength(Tasks.MaxNotes)] string notes = "") + { + var (checkedTitle, checkedNotes) = Tasks.Validate(new TaskInput(title, notes, false)); + + return Tasks.Add(store, checkedTitle, checkedNotes, false); + } + + [Tool(Idempotent = true)] + [Summary("Ticks a task off, or opens it again.")] + public TaskItem CompleteTask([Summary("The id of the task, as list_tasks names it")] string id, + [Summary("Whether it is done - true unless it is to be opened again")] bool done = true) + { + var task = store.Find(id) ?? throw Tasks.Missing(id); + + return store.Change(id, task.Title, task.Notes, done) ?? throw Tasks.Missing(id); + } + + [Tool(Destructive = true, Idempotent = true)] + [Summary("Removes a task from the list, for everybody.")] + public string RemoveTask([Summary("The id of the task, as list_tasks names it")] string id) + => store.Remove(id) ? $"Removed task '{id}'." : throw Tasks.Missing(id); + +} diff --git a/src/GenHTTP.Lambda/Resources/Templates/DemoRegistration.cs.txt b/src/GenHTTP.Lambda/Resources/Templates/DemoRegistration.cs.txt index 7bf76e8c..4f934940 100644 --- a/src/GenHTTP.Lambda/Resources/Templates/DemoRegistration.cs.txt +++ b/src/GenHTTP.Lambda/Resources/Templates/DemoRegistration.cs.txt @@ -80,7 +80,7 @@ var account = Inline.Create() * route then simply asks for a Member parameter: the user injector fills it in. */ var session = ApiKeyAuthentication.Create() - .Extractor(request => request.Header.Headers.GetCookie(Session.Cookie)) + .WithCookie(Session.Cookie) .Authenticator((_, token) => ValueTask.FromResult(accounts.Authenticate(token))); var api = Inline.Create() diff --git a/src/GenHTTP.Lambda/Resources/Templates/DemoRegistrationDecisions.md.txt b/src/GenHTTP.Lambda/Resources/Templates/DemoRegistrationDecisions.md.txt index 51fe149f..00dafef1 100644 --- a/src/GenHTTP.Lambda/Resources/Templates/DemoRegistrationDecisions.md.txt +++ b/src/GenHTTP.Lambda/Resources/Templates/DemoRegistrationDecisions.md.txt @@ -21,7 +21,7 @@ The pages keep nothing: `session.js` calls the routes with relative paths and th ## The Authentication module guards `api/` -`ApiKeyAuthentication` with an extractor that reads the cookie rather than a header, and an authenticator that turns the token into a `Member` or null. Without a cookie it answers 401, with one it does not know 403, before any route runs. The routes ask for a `Member` parameter and `UserInjector` fills it in, so no route can forget to check. The pages take either as signed out: the members page sends the visitor back to the landing page, and shows any other failure as it is. +`ApiKeyAuthentication` reading the token from the cookie (`WithCookie`) rather than a header, and an authenticator that turns the token into a `Member` or null. Without a cookie it answers 401, with one it does not know 403, before any route runs. The routes ask for a `Member` parameter and `UserInjector` fills it in, so no route can forget to check. The pages take either as signed out: the members page sends the visitor back to the landing page, and shows any other failure as it is. ## The pages are not what is protected diff --git a/src/GenHTTP.Lambda/Services/Building/BuildModel.cs b/src/GenHTTP.Lambda/Services/Building/BuildModel.cs index 25aa384f..14a0a9ae 100644 --- a/src/GenHTTP.Lambda/Services/Building/BuildModel.cs +++ b/src/GenHTTP.Lambda/Services/Building/BuildModel.cs @@ -1,4 +1,4 @@ -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; namespace GenHTTP.Lambda.Services.Building; diff --git a/src/GenHTTP.Lambda/Services/Deployment/Compilation/ModuleCatalog.cs b/src/GenHTTP.Lambda/Services/Deployment/Compilation/ModuleCatalog.cs index 179abaae..37254038 100644 --- a/src/GenHTTP.Lambda/Services/Deployment/Compilation/ModuleCatalog.cs +++ b/src/GenHTTP.Lambda/Services/Deployment/Compilation/ModuleCatalog.cs @@ -31,6 +31,8 @@ public static class ModuleCatalog "IO", "Layouting", "LoadBalancing", + // tools for AI agents, served over the Model Context Protocol + "Mcp", "OpenApi", "Pages", "Protobuf", @@ -122,6 +124,9 @@ private static string[] BuildImports() string[] api = [ "GenHTTP.Api.Content", + // [Summary], [Remarks], [Tag], [Response]: what a webservice says + // about itself in its OpenAPI description, and a tool to an agent + "GenHTTP.Api.Content.Documentation", "GenHTTP.Api.Content.IO", "GenHTTP.Api.Infrastructure", "GenHTTP.Api.Protocol" diff --git a/src/GenHTTP.Lambda/Services/Deployment/Model/CompilationDiagnostic.cs b/src/GenHTTP.Lambda/Services/Deployment/Model/CompilationDiagnostic.cs index f636f68f..47b61639 100644 --- a/src/GenHTTP.Lambda/Services/Deployment/Model/CompilationDiagnostic.cs +++ b/src/GenHTTP.Lambda/Services/Deployment/Model/CompilationDiagnostic.cs @@ -1,4 +1,4 @@ -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; namespace GenHTTP.Lambda.Services.Deployment.Model; diff --git a/src/GenHTTP.Lambda/Services/Deployment/Model/CompletionItem.cs b/src/GenHTTP.Lambda/Services/Deployment/Model/CompletionItem.cs index 3cc2167c..b838c180 100644 --- a/src/GenHTTP.Lambda/Services/Deployment/Model/CompletionItem.cs +++ b/src/GenHTTP.Lambda/Services/Deployment/Model/CompletionItem.cs @@ -1,4 +1,4 @@ -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; namespace GenHTTP.Lambda.Services.Deployment.Model; diff --git a/src/GenHTTP.Lambda/Services/Deployment/Model/LambdaChanges.cs b/src/GenHTTP.Lambda/Services/Deployment/Model/LambdaChanges.cs index 4e0899fe..b8475499 100644 --- a/src/GenHTTP.Lambda/Services/Deployment/Model/LambdaChanges.cs +++ b/src/GenHTTP.Lambda/Services/Deployment/Model/LambdaChanges.cs @@ -1,4 +1,4 @@ -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; using GenHTTP.Lambda.Services.Meta; diff --git a/src/GenHTTP.Lambda/Services/Deployment/Model/LambdaSource.cs b/src/GenHTTP.Lambda/Services/Deployment/Model/LambdaSource.cs index aaab283e..0433fa78 100644 --- a/src/GenHTTP.Lambda/Services/Deployment/Model/LambdaSource.cs +++ b/src/GenHTTP.Lambda/Services/Deployment/Model/LambdaSource.cs @@ -2,7 +2,7 @@ using System.Text.Json; using System.Text.Json.Serialization; -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; namespace GenHTTP.Lambda.Services.Deployment.Model; diff --git a/src/GenHTTP.Lambda/Services/Meta/DemoCatalog.cs b/src/GenHTTP.Lambda/Services/Meta/DemoCatalog.cs index b17aba5b..2c154f9a 100644 --- a/src/GenHTTP.Lambda/Services/Meta/DemoCatalog.cs +++ b/src/GenHTTP.Lambda/Services/Meta/DemoCatalog.cs @@ -29,15 +29,15 @@ public static class DemoCatalog [ new("demo-crud", "Records you can list, add, change and remove", "Keep track of things", "A list people can add to, change and tick off - tasks, notes, bookmarks or a small inventory.", - "A task list: a REST API with list, search, read, create, update and delete, kept in the lambda's database, with an OpenAPI document, Scalar to try it, and a page that uses it.", - "A class-based webservice ([ResourceMethod]), status codes (201, 400, 404), validation, a table made by an Evolve migration shipped in resources/migrations/, a store with an Entity Framework Core DbContext mapping it (LINQ, EF.Functions.Like, AsNoTracking, ExecuteDelete, a value converter for times), a transaction and a size cap, synchronous calls on a context per request, OpenAPI and Scalar, and a front end shipped as resources in a folder.", - "Anything that keeps a list of things: notes, bookmarks, inventory, a small admin tool.", + "A task list: a REST API with list, search, read, create, update and delete, kept in the lambda's database, with an OpenAPI document, Scalar to try it, a page that uses it, and the same tasks as tools for AI agents over MCP.", + "A class-based webservice ([ResourceMethod]), status codes (201, 400, 404), validation, a table made by an Evolve migration shipped in resources/migrations/, a store with an Entity Framework Core DbContext mapping it (LINQ, EF.Functions.Like, AsNoTracking, ExecuteDelete, a value converter for times), a transaction and a size cap, synchronous calls on a context per request, OpenAPI and Scalar with routes described by [Summary] and [Remarks], tools for AI agents over the Model Context Protocol (Tools.Create(), [Tool], [Summary], [MaxLength]), and a front end shipped as resources in a folder.", + "Anything that keeps a list of things: notes, bookmarks, inventory, a small admin tool. Anything that describes its API, or offers tools to AI agents.", Database: true), new("demo-registration", "Registration and login", "Let people sign up", "Accounts people register and sign in with, and pages only they get to see.", "A landing page that asks you to register or sign in, and a members page only a signed in user gets to see.", - "Accounts in the lambda's database with salted PBKDF2 password hashes, peppered with a secret (Secret.Exists and Secret.Read) where the lambda has one; a name unique however it is written and taken by one registration only (the key of the table, its violation caught as DbUpdateException); two tables mapped with Entity Framework Core, a navigation between them and one converter for every DateTime; sessions in an HttpOnly, SameSite cookie set with Result.Cookie and read with GetCookie, stored only as hashes and expiring, removed with their account by a foreign key; the Authentication module (ApiKeyAuthentication with a cookie extractor) guarding api/; the signed in user injected into routes with UserInjector; and two pages that decide where to send you.", + "Accounts in the lambda's database with salted PBKDF2 password hashes, peppered with a secret (Secret.Exists and Secret.Read) where the lambda has one; a name unique however it is written and taken by one registration only (the key of the table, its violation caught as DbUpdateException); two tables mapped with Entity Framework Core, a navigation between them and one converter for every DateTime; sessions in an HttpOnly, SameSite cookie set with Result.Cookie, stored only as hashes and expiring, removed with their account by a foreign key; the Authentication module (ApiKeyAuthentication reading the session WithCookie) guarding api/; the signed in user injected into routes with UserInjector; and two pages that decide where to send you.", "Anything with users: sign up, sign in, sign out, content only members see. Anything that needs a secret.", Secrets: ["PASSWORD_PEPPER"], Database: true), diff --git a/src/GenHTTP.Lambda/Services/Meta/EventReader.cs b/src/GenHTTP.Lambda/Services/Meta/EventReader.cs index e0a0adc4..c3effedc 100644 --- a/src/GenHTTP.Lambda/Services/Meta/EventReader.cs +++ b/src/GenHTTP.Lambda/Services/Meta/EventReader.cs @@ -1,4 +1,4 @@ -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; using GenHTTP.Lambda.Data; using GenHTTP.Lambda.Data.Entities; diff --git a/src/GenHTTP.Lambda/Services/Meta/TemplateCatalog.cs b/src/GenHTTP.Lambda/Services/Meta/TemplateCatalog.cs index 0f6e9d36..67e76e52 100644 --- a/src/GenHTTP.Lambda/Services/Meta/TemplateCatalog.cs +++ b/src/GenHTTP.Lambda/Services/Meta/TemplateCatalog.cs @@ -32,7 +32,7 @@ public static class TemplateCatalog new(EmptyId, "Empty"), new("demo-crud", "DemoCrud", - ("Tasks.cs", "DemoCrudTasks"), ("Store.cs", "DemoCrudStore"), + ("Tasks.cs", "DemoCrudTasks"), ("Tools.cs", "DemoCrudTools"), ("Store.cs", "DemoCrudStore"), ("resources/migrations/V1__Create_tasks.sql", "DemoCrudMigration1"), ("resources/web/index.html", "DemoCrudPage"), ("resources/web/app.js", "DemoCrudApp"), ("resources/web/base.css", "DemoStyle"), (LambdaSource.ProductDoc, "DemoCrudProduct"), (LambdaSource.DecisionsDoc, "DemoCrudDecisions"), diff --git a/src/GenHTTP.Lambda/Services/Meta/VersionFactsCache.cs b/src/GenHTTP.Lambda/Services/Meta/VersionFactsCache.cs index 347381a1..575c9e74 100644 --- a/src/GenHTTP.Lambda/Services/Meta/VersionFactsCache.cs +++ b/src/GenHTTP.Lambda/Services/Meta/VersionFactsCache.cs @@ -1,7 +1,7 @@ using System.Collections.Concurrent; using System.Text.RegularExpressions; -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; using GenHTTP.Lambda.Services.Databases; using GenHTTP.Lambda.Services.Deployment.Model; diff --git a/src/GenHTTP.Lambda/Services/Telemetry/LambdaActivity.cs b/src/GenHTTP.Lambda/Services/Telemetry/LambdaActivity.cs index 08676fa4..4877a91b 100644 --- a/src/GenHTTP.Lambda/Services/Telemetry/LambdaActivity.cs +++ b/src/GenHTTP.Lambda/Services/Telemetry/LambdaActivity.cs @@ -1,4 +1,4 @@ -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; namespace GenHTTP.Lambda.Services.Telemetry; diff --git a/src/GenHTTP.Lambda/Services/Telemetry/LambdaTraffic.cs b/src/GenHTTP.Lambda/Services/Telemetry/LambdaTraffic.cs index 9e04c6f6..a1228b36 100644 --- a/src/GenHTTP.Lambda/Services/Telemetry/LambdaTraffic.cs +++ b/src/GenHTTP.Lambda/Services/Telemetry/LambdaTraffic.cs @@ -1,4 +1,4 @@ -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; namespace GenHTTP.Lambda.Services.Telemetry; diff --git a/src/GenHTTP.Lambda/Services/Telemetry/TelemetrySample.cs b/src/GenHTTP.Lambda/Services/Telemetry/TelemetrySample.cs index 0368481d..d012892f 100644 --- a/src/GenHTTP.Lambda/Services/Telemetry/TelemetrySample.cs +++ b/src/GenHTTP.Lambda/Services/Telemetry/TelemetrySample.cs @@ -1,4 +1,4 @@ -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; namespace GenHTTP.Lambda.Services.Telemetry; diff --git a/src/GenHTTP.Lambda/Services/Workspace/WorkspaceModel.cs b/src/GenHTTP.Lambda/Services/Workspace/WorkspaceModel.cs index b549562c..e3a81a76 100644 --- a/src/GenHTTP.Lambda/Services/Workspace/WorkspaceModel.cs +++ b/src/GenHTTP.Lambda/Services/Workspace/WorkspaceModel.cs @@ -1,4 +1,4 @@ -using GenHTTP.Api.Content.Services; +using GenHTTP.Api.Content.Documentation; namespace GenHTTP.Lambda.Services.Workspace; diff --git a/tests/GenHTTP.Lambda.Tests/Api/ApiDocumentationTests.cs b/tests/GenHTTP.Lambda.Tests/Api/ApiDocumentationTests.cs index 97127335..7f3e8ec0 100644 --- a/tests/GenHTTP.Lambda.Tests/Api/ApiDocumentationTests.cs +++ b/tests/GenHTTP.Lambda.Tests/Api/ApiDocumentationTests.cs @@ -102,19 +102,33 @@ public async Task WhatOnlyTheOperatorMayCallAsksForTheToken() Assert.IsNotEmpty(operations, "an installation with a token describes its panel"); + var scheme = document.RootElement.GetProperty("components").GetProperty("securitySchemes").GetProperty("adminToken"); + + Assert.AreEqual("apiKey", scheme.GetProperty("type").GetString()); + Assert.AreEqual("header", scheme.GetProperty("in").GetString(), "the token is presented in a header"); + Assert.AreEqual("X-Admin-Token", scheme.GetProperty("name").GetString()); + Assert.Contains("LAMBDA_ADMIN_TOKEN", scheme.GetProperty("description").GetString()!, "it says where the token comes from"); + foreach (var (name, operation) in operations) { - var token = operation.GetProperty("parameters").EnumerateArray() - .FirstOrDefault(p => p.GetProperty("name").GetString() == "X-Admin-Token"); + var required = operation.GetProperty("security").EnumerateArray().Select(r => r.EnumerateObject().Select(p => p.Name).ToList()).ToList(); - Assert.AreEqual("header", token.GetProperty("in").GetString(), $"{name} asks for the token in a header"); - Assert.IsTrue(token.GetProperty("required").GetBoolean(), $"{name} requires the token"); + Assert.HasCount(1, required, $"{name} is called one way"); + CollectionAssert.AreEqual(new[] { "adminToken" }, required[0], $"{name} requires the token"); var responses = operation.GetProperty("responses"); Assert.IsTrue(responses.TryGetProperty("401", out _), $"{name} says how a missing token is answered"); Assert.IsTrue(responses.TryGetProperty("403", out _), $"{name} says how a wrong token is answered"); } + + var panel = operations.Select(o => o.Name).ToHashSet(); + + var others = Operations(document).Where(o => !panel.Contains(o.Name) && o.Operation.TryGetProperty("security", out _)) + .Select(o => o.Name) + .ToList(); + + Assert.IsEmpty(others, $"everywhere else the editor key is the credential, not the token: {string.Join(", ", others)}"); } #endregion diff --git a/tests/GenHTTP.Lambda.Tests/Api/McpTests.cs b/tests/GenHTTP.Lambda.Tests/Api/McpTests.cs index 6de6d9ba..cb7dd1f3 100644 --- a/tests/GenHTTP.Lambda.Tests/Api/McpTests.cs +++ b/tests/GenHTTP.Lambda.Tests/Api/McpTests.cs @@ -41,32 +41,6 @@ public async Task ItIntroducesItself() "the instructions have to say how to get from nothing to something online"); } - [TestMethod] - public async Task AVersionItDoesNotKnowIsAnsweredWithOneItDoes() - { - await using var fixture = await LambdaFixture.CreateAsync(); - - var answer = await CallAsync(fixture, "initialize", new JsonObject { ["protocolVersion"] = "1999-01-01" }); - - Assert.AreEqual("2025-06-18", answer["result"]!["protocolVersion"]!.GetValue(), - "the client decides whether it can live with that, rather than being refused outright"); - } - - [TestMethod] - public async Task ANotificationIsAcknowledgedAndNotAnswered() - { - await using var fixture = await LambdaFixture.CreateAsync(); - - using var response = await PostAsync(fixture, new JsonObject - { - ["jsonrpc"] = "2.0", - ["method"] = "notifications/initialized" - }); - - Assert.AreEqual(HttpStatusCode.Accepted, response.StatusCode); - Assert.IsEmpty(await response.Content.ReadAsStringAsync()); - } - [TestMethod] public async Task EveryToolSaysWhatItTakes() { @@ -230,10 +204,20 @@ public async Task AnAgentIsToldWhichViewsThereAre() var privateKey = made["privateKey"]!.GetValue(); + var tools = (JsonArray)(await CallAsync(fixture, "tools/list", new JsonObject()))["result"]!["tools"]!; + + var views = tools.Single(t => t!["name"]!.GetValue() == "update_lambda")!["inputSchema"]!["properties"]!["view"]!["enum"]!.AsArray() + .Select(v => v!.GetValue()) + .Order() + .ToList(); + + CollectionAssert.AreEqual(new[] { "Full", "Simple" }, views, "the tool names the views there are"); + var refused = await CallToolAsync(fixture, "update_lambda", new JsonObject { ["privateKey"] = privateKey, ["view"] = "minimal" }); Assert.IsTrue(refused["result"]!["isError"]!.GetValue()); - Assert.Contains("Simple", Structured(refused)["problem"]!.GetValue(), "the refusal names the views there are"); + Assert.Contains("view", Problem(refused), "the refusal says which argument does not fit"); + Assert.AreEqual("Full", fixture.Meta.Get(privateKey)!.View, "and nothing was changed"); var missing = await CallToolAsync(fixture, "update_lambda", new JsonObject { ["privateKey"] = privateKey }); @@ -447,7 +431,7 @@ public async Task AnEditThatMatchesTwiceIsRefused() }); Assert.IsTrue(answer["result"]!["isError"]!.GetValue()); - Assert.Contains("more than once", Structured(answer)["problem"]!.GetValue()); + Assert.Contains("more than once", Problem(answer)); } [TestMethod] @@ -962,7 +946,7 @@ public async Task AFeatureBehindTheNewestVersionIsMergedOnlyOnceItsBaseMoves() Assert.IsTrue(refused["result"]!["isError"]!.GetValue()); - var problem = Structured(refused)["problem"]!.GetValue(); + var problem = Problem(refused); Assert.Contains("version 3", problem); Assert.Contains("update_feature", problem, "the refusal says how to get there"); @@ -1159,7 +1143,7 @@ public async Task AnAgentIsToldWhenTheWorkspaceIsOff() }); Assert.IsTrue(refused["result"]!["isError"]!.GetValue()); - Assert.Contains("switched off", Structured(refused)["problem"]!.GetValue()); + Assert.Contains("switched off", Problem(refused)); } [TestMethod] @@ -1178,7 +1162,7 @@ public async Task AnAgentIsToldWhenWhatItUploadsIsNotBase64() }); Assert.IsTrue(refused["result"]!["isError"]!.GetValue(), "an answer to read and act on, not a fault"); - Assert.Contains("base64", Structured(refused)["problem"]!.GetValue()); + Assert.Contains("base64", Problem(refused)); var listed = Structured(await CallToolAsync(fixture, "list_files", new JsonObject { ["privateKey"] = lambda.PrivateKey })); @@ -1316,7 +1300,7 @@ public async Task ADemoCannotBeChangedByAnAgent() }); Assert.IsTrue(write["result"]!["isError"]!.GetValue()); - Assert.Contains("create_lambda", Structured(write)["problem"]!.GetValue(), "the refusal says what to do instead"); + Assert.Contains("create_lambda", Problem(write), "the refusal says what to do instead"); var upload = await CallToolAsync(fixture, "upload_file", new JsonObject { @@ -1347,45 +1331,29 @@ public async Task ADemoCannotBeChangedByAnAgent() } [TestMethod] - public async Task AGetIsToldThereIsNoStream() + public async Task OnlyABrowserOnAHostTheOperatorNamedMayUseIt() { - await using var fixture = await LambdaFixture.CreateAsync(); + // an entry that names no origin is left out, rather than keep the server from starting + await using var fixture = await LambdaFixture.CreateAsync(o => o with { McpOrigins = ["console.example", "http://localhost:5173", "not a host"] }); - using var response = await fixture.GetAsync("/mcp"); - - Assert.AreEqual(HttpStatusCode.MethodNotAllowed, response.StatusCode, - "which is how the specification says to decline one"); - } + async Task FromAsync(string origin) + { + using var request = fixture.Host.GetRequest("/mcp", HttpMethod.Post); - [TestMethod] - public async Task ABrowserFromSomewhereElseIsTurnedAway() - { - await using var fixture = await LambdaFixture.CreateAsync(); + request.Headers.Add("Origin", origin); + request.Content = new StringContent("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}", System.Text.Encoding.UTF8, "application/json"); - using var request = fixture.Host.GetRequest("/mcp", HttpMethod.Post); + using var response = await fixture.Host.GetResponseAsync(request); - request.Headers.Add("Origin", "https://not-this-server.example"); - request.Content = new StringContent("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}"); + return response.StatusCode; + } - using var response = await fixture.Host.GetResponseAsync(request); + Assert.AreEqual(HttpStatusCode.OK, await FromAsync("https://console.example"), "a host the operator named stands for its page over HTTPS"); + Assert.AreEqual(HttpStatusCode.OK, await FromAsync("http://localhost:5173"), "an origin the operator named is taken as it is"); - Assert.AreEqual(HttpStatusCode.Forbidden, response.StatusCode, + Assert.AreEqual(HttpStatusCode.Forbidden, await FromAsync("https://not-this-server.example"), "an agent sends no origin; only a browser does, and only a browser can be aimed by somebody else"); - } - - [TestMethod] - public async Task AProtocolVersionItDoesNotKnowIsRefused() - { - await using var fixture = await LambdaFixture.CreateAsync(); - - using var request = fixture.Host.GetRequest("/mcp", HttpMethod.Post); - - request.Headers.Add("MCP-Protocol-Version", "1999-01-01"); - request.Content = new StringContent("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}"); - - using var response = await fixture.Host.GetResponseAsync(request); - - Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); + Assert.AreEqual(HttpStatusCode.Forbidden, await FromAsync("http://console.example"), "nor is the host's page over plain HTTP, which the platform never serves"); } [TestMethod] @@ -1595,6 +1563,13 @@ public async Task TheGuideSaysWhatAPublishedSourceHoldsAndWhatItNeverDoes() var tool = tools.Single(t => t!["name"]!.GetValue() == "open_source")!; Assert.Contains("never its data", tool["description"]!.GetValue()); + + var license = tool["inputSchema"]!["properties"]!["license"]!["description"]!.GetValue(); + + foreach (var offered in GenHTTP.Lambda.Services.Source.SourceLicenses.All) + { + Assert.Contains(offered.Id, license, "the tool names every license there is to choose from"); + } Assert.IsFalse(tool["annotations"]!["readOnlyHint"]!.GetValue()); } @@ -1602,6 +1577,11 @@ public async Task TheGuideSaysWhatAPublishedSourceHoldsAndWhatItNeverDoes() private static JsonObject Structured(JsonObject answer) => (JsonObject)answer["result"]!["structuredContent"]!; + /// + /// What a refusal says, as the model reads it. + /// + private static string Problem(JsonObject answer) => answer["result"]!["content"]![0]!["text"]!.GetValue(); + private static Task CallToolAsync(LambdaFixture fixture, string tool, JsonObject arguments) => CallAsync(fixture, "tools/call", new JsonObject { ["name"] = tool, ["arguments"] = arguments }); diff --git a/tests/GenHTTP.Lambda.Tests/Catalog/DemoBehaviourTests.cs b/tests/GenHTTP.Lambda.Tests/Catalog/DemoBehaviourTests.cs index e9e12420..b5e01367 100644 --- a/tests/GenHTTP.Lambda.Tests/Catalog/DemoBehaviourTests.cs +++ b/tests/GenHTTP.Lambda.Tests/Catalog/DemoBehaviourTests.cs @@ -67,6 +67,60 @@ public async Task TasksCanBeListedAddedChangedAndRemoved() using var specification = await fixture.GetAsync("http://demo-crud.localhost/openapi.json"); Assert.AreEqual(HttpStatusCode.OK, specification.StatusCode, "the API describes itself"); + + var routes = JsonNode.Parse(await specification.Content.ReadAsStringAsync())!["paths"]!["/tasks/"]!; + + Assert.AreEqual("Adds a task", routes["post"]!["summary"]!.GetValue(), "and says what each route does"); + } + + [TestMethod] + public async Task AnAgentKeepsTheSameTasksWithToolsOfItsOwn() + { + await using var fixture = await LambdaFixture.CreateAsync(); + + await fixture.SeedDemosAsync(); + + var added = await ToolAsync("add_task", new JsonObject { ["title"] = "Water the plants" }); + + var id = added["structuredContent"]!["id"]!.GetValue(); + + using (var read = await fixture.GetAsync($"http://demo-crud.localhost/tasks/{id}", "application/json")) + { + Assert.AreEqual(HttpStatusCode.OK, read.StatusCode, "what an agent adds is on the list everybody sees"); + } + + var done = await ToolAsync("complete_task", new JsonObject { ["id"] = id }); + + Assert.IsTrue(done["structuredContent"]!["done"]!.GetValue()); + + var untitled = await ToolAsync("add_task", new JsonObject { ["title"] = " " }); + + Assert.IsTrue(untitled["isError"]!.GetValue(), "what it sends is checked as the API checks it"); + Assert.Contains("needs a title", untitled["content"]![0]!["text"]!.GetValue(), "and it reads why"); + + var removed = await ToolAsync("remove_task", new JsonObject { ["id"] = id }); + + Assert.IsFalse(removed["isError"]!.GetValue()); + + using (var gone = await fixture.GetAsync($"http://demo-crud.localhost/tasks/{id}", "application/json")) + { + Assert.AreEqual(HttpStatusCode.NotFound, gone.StatusCode); + } + + async Task ToolAsync(string name, JsonObject arguments) + { + using var response = await fixture.SendAsync(HttpMethod.Post, "http://demo-crud.localhost/mcp", new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = 1, + ["method"] = "tools/call", + ["params"] = new JsonObject { ["name"] = name, ["arguments"] = arguments } + }); + + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + + return JsonNode.Parse(await response.Content.ReadAsStringAsync())!["result"]!; + } } [TestMethod] diff --git a/tests/GenHTTP.Lambda.Tests/Execution/DescriptionTests.cs b/tests/GenHTTP.Lambda.Tests/Execution/DescriptionTests.cs new file mode 100644 index 00000000..37255560 --- /dev/null +++ b/tests/GenHTTP.Lambda.Tests/Execution/DescriptionTests.cs @@ -0,0 +1,111 @@ +using System.Net; +using System.Text.Json.Nodes; + +using GenHTTP.Lambda.Tests.Infrastructure; + +using GenHTTP.Testing; + +namespace GenHTTP.Lambda.Tests.Execution; + +/// +/// What a lambda can say about itself to whoever calls it: tools for an AI +/// agent, and the description of its API. +/// +[TestClass] +public sealed class DescriptionTests +{ + + [TestMethod] + public async Task ALambdaCanOfferToolsToAnAgent() + { + await using var fixture = await LambdaFixture.CreateAsync(); + + var lambda = await fixture.CreateLambdaAsync("abacus"); + + // no using: the module and what describes a tool are imported for every lambda + await fixture.DeployAsync(lambda.PrivateKey, """ + var tools = Tools.Create() + .Server("abacus", "1.0.0") + .Add(); + + return Layout.Create().Add("mcp", tools); + + public sealed class Abacus + { + + [Tool(ReadOnly = true)] + [Summary("Adds two numbers")] + public int Add([Summary("The first")] int a, [System.ComponentModel.DataAnnotations.Range(0, 10)] int b) => a + b; + + } + """); + + var listed = await CallAsync(fixture, "http://abacus.localhost/mcp", "tools/list", new JsonObject()); + + var tool = listed["result"]!["tools"]![0]!; + + Assert.AreEqual("add", tool["name"]!.GetValue()); + Assert.AreEqual("Adds two numbers", tool["description"]!.GetValue()); + Assert.AreEqual(10, tool["inputSchema"]!["properties"]!["b"]!["maximum"]!.GetValue(), "what the lambda allows is said to the agent"); + + var called = await CallAsync(fixture, "http://abacus.localhost/mcp", "tools/call", new JsonObject + { + ["name"] = "add", + ["arguments"] = new JsonObject { ["a"] = 2, ["b"] = 3 } + }); + + Assert.AreEqual("5", called["result"]!["content"]![0]!["text"]!.GetValue()); + } + + [TestMethod] + public async Task ALambdaDescribesItsApiInItsOwnWords() + { + await using var fixture = await LambdaFixture.CreateAsync(); + + var lambda = await fixture.CreateLambdaAsync("greeter"); + + await fixture.DeployAsync(lambda.PrivateKey, """ + return Layout.Create() + .AddService("greetings") + .Add(ApiDescription.Create()); + + [Tag("Greetings")] + public sealed class Greetings + { + + [ResourceMethod] + [Summary("Greets whoever asks")] + [Remarks("Always in English.")] + public string Greet() => "hello"; + + } + """); + + using var response = await fixture.GetAsync("http://greeter.localhost/openapi.json"); + + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + + var document = JsonNode.Parse(await response.GetContentAsync())!; + + var operation = document["paths"]!["/greetings/"]!["get"]!; + + Assert.AreEqual("Greets whoever asks", operation["summary"]!.GetValue()); + Assert.AreEqual("Always in English.", operation["description"]!.GetValue()); + } + + private static async Task CallAsync(LambdaFixture fixture, string address, string method, JsonObject parameters) + { + using var response = await fixture.SendAsync(HttpMethod.Post, address, new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = 1, + ["method"] = method, + ["params"] = parameters + }); + + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + + return (JsonObject)JsonNode.Parse(await response.GetContentAsync())!; + } + +}