Skip to content

mcp-beaver serve-s3 <spec.mcp.kdl> --http :addr

The AWS SDK-backed asset publisher, and the first write-capable mode in this runtime. Its KDL policy fixes one bucket, the media types it will serve, the public base URL it hands back, and an optional key prefix.

wrap ward mcp aws s3 {
    region "us-east-1"
    bucket "coilysiren-public-assets"
    base-url "https://files.coilysiren.me"
    prefix "dowel"
    max-bytes 8388608
    content-type "image/png"
    content-type "application/json"
    can put object
    can list objects
    can get object-url
}

Three tools, and the policy must grant all three:

  • put_object(key, content_base64, content_type) returns the public URL.
  • list_objects(prefix) lists what is already published, confined to the policy prefix.
  • get_object_url(key) confirms the object exists, then returns its URL.

Why the guardfile carries more than IAM does

serve-ssm reads one parameter, so its whole boundary is one ARN and IAM can state it. A publisher is different in kind. The caller supplies the bytes, the key, and the content type, and to IAM every PutObject inside the bucket looks alike. Several bounds therefore exist only here:

  • Content type is an exact allowlist, and the parser refuses text/html, image/svg+xml, and the other markup types a browser executes in the origin's own security context. A bucket behind a hostname that also carries other published work would otherwise let an uploader script against that origin. SVG is the non-obvious one, an image everywhere else and a scriptable document here. A guardfile listing one of these fails to start rather than starting and quietly never allowing it.
  • Keys are a narrow character set of letters, digits, dash, underscore, dot, and slash, with no traversal segments and no empty ones. A key is a public URL path, and anything outside that set is refused rather than escaped, because an escaped key round-trips differently through a CDN, a bucket listing, and a chat client that linkifies it.
  • Size is capped from the decoded bytes, never from a length the caller states. max-bytes defaults to 8 MiB.

What it deliberately cannot do

There is no delete tool. Publishing is the grant and unpublishing is not, which is also why the workload user's IAM policy withholds s3:DeleteObject rather than relying on the absence of a tool. put_object does overwrite an existing key, so the bucket keeps versions and that overwrite stays recoverable.

put_object advertises readOnlyHint: false and idempotentHint: false. The same key twice serves different bytes the second time, and a roster that trusts annotations would otherwise treat an upload as a safe call.

The URL is the CDN's

The bucket is private. Readers reach it through CloudFront, so base-url is the distribution's hostname and the tools never hand back an S3 endpoint. A bucket URL would 403 for every reader it was given to.

Feeding it bytes

The bytes arrive base64-encoded in the tool call, which is the only way an agent hands a file over JSON. That works for what an agent generates itself, a chart it rendered, a JSON export, a log bundle. It does not chain from an image another tool returned, because that image reaches the model as content rather than as text it can re-emit.

serve-s3 policies use a separate grammar from the wrap inline one, so they are not lintable through lint.

See also: ssm.md, serve.md.