@aotter/mantle 0.2.1-alpha.4 → 0.2.2-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/dist/auth/routes.d.ts +3 -1
  2. package/dist/auth/routes.js +15 -2
  3. package/dist/build/compiler.d.ts +1 -0
  4. package/dist/build/compiler.js +2 -2
  5. package/dist/build/spec/kernel/diagnostic.d.ts +1 -1
  6. package/dist/bun/assets.d.ts +3 -1
  7. package/dist/bun/assets.js +4 -1
  8. package/dist/domain/describe/chunk-GMUORMOX.js +1 -0
  9. package/dist/domain/describe/index.js +1 -1
  10. package/dist/domain/describe/public.js +1 -1
  11. package/dist/domain/generated-tools.js +24 -9
  12. package/dist/infrastructure/bundle.js +30 -1
  13. package/dist/infrastructure/inputs.js +3 -2
  14. package/dist/infrastructure/wrangler.js +4 -3
  15. package/dist/kernel/authorization.d.ts +1 -0
  16. package/dist/kernel/authorization.js +12 -5
  17. package/dist/kernel/mcp-call.d.ts +3 -3
  18. package/dist/kernel/mcp-call.js +4 -4
  19. package/dist/kernel/mcp-handler.js +4 -3
  20. package/dist/kernel/mcp.d.ts +12 -10
  21. package/dist/kernel/mcp.js +1 -1
  22. package/dist/kernel/media-fetch.js +109 -0
  23. package/dist/kernel/media.d.ts +15 -2
  24. package/dist/kernel/media.js +138 -16
  25. package/dist/kernel/r2-media.d.ts +3 -2
  26. package/dist/kernel/r2-media.js +12 -8
  27. package/dist/kernel/result-meta.d.ts +10 -0
  28. package/dist/kernel/result-meta.js +1 -0
  29. package/dist/kernel/site-types.d.ts +54 -6
  30. package/dist/kernel/site.d.ts +1 -1
  31. package/dist/usecase/generate.js +2 -2
  32. package/dist/usecase/mcp-media.js +17 -5
  33. package/dist/usecase/mcp.js +6 -5
  34. package/dist/usecase/site.js +4 -2
  35. package/docs/examples/README.md +5 -1
  36. package/docs/examples/app-shell/README.md +7 -3
  37. package/docs/examples/app-shell/package.json +9 -9
  38. package/docs/examples/app-shell/src/jwks.ts +22 -0
  39. package/docs/examples/app-shell/src/service.ts +12 -1
  40. package/docs/examples/app-shell/wrangler.jsonc +1 -1
  41. package/docs/examples/club-events/README.md +10 -3
  42. package/docs/examples/club-events/manifests/club-events.yaml +42 -11
  43. package/docs/examples/club-events/smoke.mjs +31 -1
  44. package/docs/examples/club-events/src/handlers.ts +9 -8
  45. package/docs/examples/frontend-mcp-app/README.md +23 -0
  46. package/docs/examples/frontend-mcp-app/app.tsx +50 -0
  47. package/docs/examples/frontend-mcp-app/manifest.yaml +30 -0
  48. package/docs/examples/purchasing/README.md +9 -0
  49. package/docs/examples/purchasing/manifests/purchasing.yaml +18 -16
  50. package/docs/examples/purchasing/smoke.mjs +5 -0
  51. package/docs/examples/purchasing/wrangler.jsonc +1 -1
  52. package/docs/examples/recipes/wrangler.jsonc +1 -1
  53. package/docs/handbook/cloudflare/authentication.md +51 -0
  54. package/docs/handbook/cloudflare/deploy-and-operate.md +5 -0
  55. package/docs/handbook/cloudflare/media-r2.md +5 -2
  56. package/docs/handbook/cloudflare/service-entry.md +28 -7
  57. package/docs/handbook/concepts/runtime-and-adapters.md +1 -1
  58. package/docs/handbook/examples/hub.md +1 -1
  59. package/docs/handbook/guides/adapter-guide.md +1 -1
  60. package/docs/handbook/guides/mcp-apps.md +94 -18
  61. package/docs/handbook/guides/native-admin.md +30 -1
  62. package/docs/handbook/guides/performance.md +5 -1
  63. package/docs/handbook/guides/revisions.md +1 -1
  64. package/docs/handbook/guides/size-and-startup.md +3 -3
  65. package/docs/handbook/guides/sql-patterns.md +34 -1
  66. package/docs/handbook/reference/authorization.md +1 -1
  67. package/docs/handbook/reference/describe.md +5 -0
  68. package/docs/handbook/reference/features.md +1 -1
  69. package/docs/handbook/reference/schema.md +19 -0
  70. package/docs/handbook/reference/site-config.md +2 -0
  71. package/docs/handbook/reference/sql.md +1 -1
  72. package/docs/handbook/reference/surface.md +66 -10
  73. package/docs/handbook/reference/view.md +1 -2
  74. package/docs/handbook/releases/index.md +131 -115
  75. package/docs/handbook/start/agents.md +2 -2
  76. package/docs/handbook/start/overview.md +1 -1
  77. package/docs/handbook/start/project-and-cli.md +1 -1
  78. package/package.json +10 -10
  79. package/dist/domain/describe/chunk-CW7X5Q5T.js +0 -1
@@ -25,7 +25,7 @@ export type Phase = "validate" | "test" | "boot" | "runtime";
25
25
  * Single source of truth: the const array drives `DiagnosticCode`
26
26
  * (type), so adding a code is one edit.
27
27
  */
28
- export declare const DIAGNOSTIC_CODES: readonly ["AUTH_ROLE_REMOVED", "USER_FIELD_VALUES_UNENUMERATED", "USER_FIELD_INVALID", "USER_FIELD_CONFLICT", "USER_FIELD_POSITION", "USER_REF_SCOPE_ONLY", "USER_SCOPE_PUBLIC_ANON_EMPTY", "RESOURCE_EXHAUSTED", "RESOURCE_UNAVAILABLE", "RATE_LIMITED", "OUTCOME_UNKNOWN", "PARTIAL_FAILURE", "PRECONDITION_FAILED", "INVALID_MANIFEST_ENVELOPE", "DUPLICATE_NAME", "VIEW_INPUT_INVALID_SHAPE", "VIEW_INPUT_RESERVED_NAME", "VIEW_CACHE_INVALID", "VIEW_UI_INVALID", "REQUIRED_FIELD_UNKNOWN", "INVALID_PATTERN", "JSON_SCHEMA_UNSUPPORTED", "JSON_SCHEMA_REF_INVALID", "JSON_SCHEMA_LIMIT_EXCEEDED", "AUTH_PREDICATE_NOT_IN_ENUM", "GUARD_PROCEDURE_UNKNOWN", "GUARD_SELF_REFERENCE", "GUARD_PROCEDURE_NOT_REF", "GUARD_CHAIN_NOT_ALLOWED", "SCHEMA_INDEX_INVALID", "SCHEMA_INDEX_FIELD_UNKNOWN", "UNIQUE_INDEX_FIELD_UNKNOWN", "SCHEMA_SEARCH_INVALID", "SCHEMA_SEARCH_FIELD_UNKNOWN", "SCHEMA_TTL_INVALID", "SCHEMA_LOCK_INVALID", "LOCK_INDEX_MISSING", "SCHEMA_PUBLISHING_INVALID", "SCHEMA_UI_INVALID", "SCHEMA_NAME_CASE_COLLISION", "SCHEMA_READER_NAME_COLLISION", "FIELD_NAME_CASE_COLLISION", "MANIFEST_ROOT_NOT_FOUND", "MANIFEST_READ_FAILED", "GENERATE_FEATURE_DEPENDENCY_MISSING", "SQL_SYNTAX", "SQL_UNSUPPORTED", "SQL_FUNCTION", "SQL_RELATION", "SQL_COLUMN", "SQL_WRITE", "SQL_SHAPE", "SQL_TYPE", "FIXTURE_SCHEMA_VIOLATION", "HANDLER_NOT_REGISTERED", "HANDLER_NOT_DECLARED", "PLAN_FINGERPRINT_MISMATCH", "SCHEDULE_NOT_WIRED", "STORAGE_CHANGE_BLOCKED", "STORAGE_TABLE_NOT_OWNED", "INVOCATION_DEPTH_EXCEEDED", "TRIGGER_TARGET_PROCEDURE_UNKNOWN", "SCHEDULE_INPUT_INVALID", "SCHEDULE_AUTH_INVALID", "TRIGGER_PATH_COLLISION", "TRIGGER_PATH_INVALID", "MCP_TOOL_NAME_COLLISION", "MCP_TOOL_DESCRIPTION_MISSING", "MCP_TOOL_INPUT_UNION_AMBIGUOUS", "MCP_TOOL_INPUT_UNBOUNDED", "MANTLE_REF_INVALID", "PROCEDURE_TARGET_INVALID", "PROCEDURE_NOT_FOUND", "NOT_FOUND", "METHOD_NOT_ALLOWED", "LIFECYCLE_SCHEMA_UNKNOWN", "LIFECYCLE_HOOK_REJECTED", "LIFECYCLE_TARGET_NOT_REF", "SCHEMA_LOCALIZED_REQUIRES_SITE_LOCALES", "TRANSLATES_PARENT_UNKNOWN", "TRANSLATES_REQUIRES_LOCALIZED", "TRANSLATES_REQUIRES_CONTENT_FIELD", "TRANSLATES_FIELD_NOT_IN_PARENT", "TRANSLATES_FIELD_NOT_IN_CHILD", "TRANSLATES_PARENT_IS_LOCALIZED", "SCHEMA_TTL_TRANSLATION_UNSUPPORTED", "INPUT_VALIDATION_FAILED", "UNAUTHENTICATED", "AUTH_DENIED", "ENTITLEMENT_REQUIRED", "CONFLICT", "DISPATCHER_NOT_BUILT", "INTERNAL_ERROR", "OUTPUT_VALIDATION_FAILED", "INVALID_LOCALE", "MEDIA_NOT_CONFIGURED", "SITE_NOT_CONFIGURED", "MEDIA_UPLOAD_EXPIRED", "MEDIA_OBJECT_NOT_FOUND", "MEDIA_MIME_REJECTED", "MEDIA_SIZE_EXCEEDED", "MEDIA_SVG_REJECTED", "MEDIA_CHECKSUM_MISMATCH", "MEDIA_PURPOSE_REJECTED", "MEDIA_VARIANTS_INCOMPLETE", "MEDIA_VARIANT_SIZE_EXCEEDED", "MEDIA_VARIANTS_SUSPICIOUS_SIZE", "MEDIA_ASSET_NOT_FOUND"];
28
+ export declare const DIAGNOSTIC_CODES: readonly ["AUTH_ROLE_REMOVED", "USER_FIELD_VALUES_UNENUMERATED", "USER_FIELD_INVALID", "USER_FIELD_CONFLICT", "USER_FIELD_POSITION", "USER_REF_SCOPE_ONLY", "USER_SCOPE_PUBLIC_ANON_EMPTY", "RESOURCE_EXHAUSTED", "RESOURCE_UNAVAILABLE", "RATE_LIMITED", "OUTCOME_UNKNOWN", "PARTIAL_FAILURE", "PRECONDITION_FAILED", "INVALID_MANIFEST_ENVELOPE", "DUPLICATE_NAME", "VIEW_INPUT_INVALID_SHAPE", "VIEW_INPUT_RESERVED_NAME", "VIEW_CACHE_INVALID", "VIEW_UI_INVALID", "REQUIRED_FIELD_UNKNOWN", "INVALID_PATTERN", "JSON_SCHEMA_UNSUPPORTED", "JSON_SCHEMA_REF_INVALID", "JSON_SCHEMA_LIMIT_EXCEEDED", "AUTH_PREDICATE_NOT_IN_ENUM", "GUARD_PROCEDURE_UNKNOWN", "GUARD_SELF_REFERENCE", "GUARD_PROCEDURE_NOT_REF", "GUARD_CHAIN_NOT_ALLOWED", "SCHEMA_INDEX_INVALID", "SCHEMA_INDEX_FIELD_UNKNOWN", "UNIQUE_INDEX_FIELD_UNKNOWN", "SCHEMA_SEARCH_INVALID", "SCHEMA_SEARCH_FIELD_UNKNOWN", "SCHEMA_TTL_INVALID", "SCHEMA_LOCK_INVALID", "LOCK_INDEX_MISSING", "SCHEMA_PUBLISHING_INVALID", "SCHEMA_UI_INVALID", "SCHEMA_NAME_CASE_COLLISION", "SCHEMA_READER_NAME_COLLISION", "FIELD_NAME_CASE_COLLISION", "MANIFEST_ROOT_NOT_FOUND", "MANIFEST_READ_FAILED", "GENERATE_FEATURE_DEPENDENCY_MISSING", "SQL_SYNTAX", "SQL_UNSUPPORTED", "SQL_FUNCTION", "SQL_RELATION", "SQL_COLUMN", "SQL_WRITE", "SQL_SHAPE", "SQL_TYPE", "FIXTURE_SCHEMA_VIOLATION", "HANDLER_NOT_REGISTERED", "HANDLER_NOT_DECLARED", "PLAN_FINGERPRINT_MISMATCH", "SCHEDULE_NOT_WIRED", "STORAGE_CHANGE_BLOCKED", "STORAGE_TABLE_NOT_OWNED", "INVOCATION_DEPTH_EXCEEDED", "TRIGGER_TARGET_PROCEDURE_UNKNOWN", "SCHEDULE_INPUT_INVALID", "SCHEDULE_AUTH_INVALID", "TRIGGER_PATH_COLLISION", "TRIGGER_PATH_INVALID", "MCP_TOOL_NAME_COLLISION", "MCP_TOOL_DESCRIPTION_MISSING", "MCP_TOOL_INPUT_UNION_AMBIGUOUS", "MCP_TOOL_INPUT_UNBOUNDED", "MANTLE_REF_INVALID", "PROCEDURE_TARGET_INVALID", "PROCEDURE_NOT_FOUND", "NOT_FOUND", "METHOD_NOT_ALLOWED", "LIFECYCLE_SCHEMA_UNKNOWN", "LIFECYCLE_HOOK_REJECTED", "LIFECYCLE_TARGET_NOT_REF", "SCHEMA_LOCALIZED_REQUIRES_SITE_LOCALES", "TRANSLATES_PARENT_UNKNOWN", "TRANSLATES_REQUIRES_LOCALIZED", "TRANSLATES_REQUIRES_CONTENT_FIELD", "TRANSLATES_FIELD_NOT_IN_PARENT", "TRANSLATES_FIELD_NOT_IN_CHILD", "TRANSLATES_PARENT_IS_LOCALIZED", "SCHEMA_TTL_TRANSLATION_UNSUPPORTED", "INPUT_VALIDATION_FAILED", "UNAUTHENTICATED", "AUTH_DENIED", "ENTITLEMENT_REQUIRED", "CONFLICT", "DISPATCHER_NOT_BUILT", "INTERNAL_ERROR", "OUTPUT_VALIDATION_FAILED", "INVALID_LOCALE", "MEDIA_NOT_CONFIGURED", "SITE_NOT_CONFIGURED", "MEDIA_UPLOAD_EXPIRED", "MEDIA_OBJECT_NOT_FOUND", "MEDIA_MIME_REJECTED", "MEDIA_SIZE_EXCEEDED", "MEDIA_SVG_REJECTED", "MEDIA_CHECKSUM_MISMATCH", "MEDIA_PURPOSE_REJECTED", "MEDIA_VARIANTS_INCOMPLETE", "MEDIA_VARIANT_SIZE_EXCEEDED", "MEDIA_VARIANTS_SUSPICIOUS_SIZE", "MEDIA_ASSET_NOT_FOUND", "MEDIA_NOT_OWNED"];
29
29
  export type DiagnosticCode = (typeof DIAGNOSTIC_CODES)[number];
30
30
  export interface SourcePosition {
31
31
  readonly line: number;
@@ -1,2 +1,4 @@
1
- /** Native file response, or null for missing or invalid paths. */
1
+ /** Native file response, or null for missing or invalid paths. Every file gets `nosniff`; Admin's content-hashed
2
+ * chunks under `/assets/` are cached for a year, as the edge `_headers` does on Workers. Other files keep no cache
3
+ * header (`adminShell` marks the shell `no-store`). */
2
4
  export declare function bunAdminAssets(root: string): (path: string) => Promise<Response | null>;
@@ -17,6 +17,9 @@ export function bunAdminAssets(root) {
17
17
  const physical = await realpath(target).catch(() => "");
18
18
  if (!physical || !physical.startsWith(`${await realpath(base)}${sep}`) || !(await stat(physical)).isFile())
19
19
  return null;
20
- return new Response(Bun.file(physical));
20
+ const headers = new Headers({ "x-content-type-options": "nosniff" });
21
+ if (path.startsWith("/assets/"))
22
+ headers.set("cache-control", "public, max-age=31536000, immutable");
23
+ return new Response(Bun.file(physical), { headers });
21
24
  };
22
25
  }
@@ -0,0 +1 @@
1
+ function canonical(v){return Array.isArray(v)?`[${v.map(canonical).join(",")}]`:v&&typeof v=="object"?`{${Object.keys(v).sort().filter(k=>v[k]!==void 0).map(k=>`${JSON.stringify(k)}:${canonical(v[k])}`).join(",")}}`:JSON.stringify(v)??"null"}function resolveLocalizedText(value,preferred,canonical2){if(value==null)return null;if(typeof value=="string")return value;if(Object.prototype.hasOwnProperty.call(value,preferred))return value[preferred];if(canonical2&&Object.prototype.hasOwnProperty.call(value,canonical2))return value[canonical2];let firstKey=Object.keys(value)[0];return firstKey!==void 0?value[firstKey]:null}var INPUT_RULE_KEYWORDS=["dependentRequired","anyOf","if","then","else"];function lookupLocalRef(root,ref){if(typeof ref!="string"||!ref.startsWith("#/"))return;let value=root;try{for(let key of decodeURIComponent(ref.slice(2)).split("/").map(key2=>key2.replace(/~1/g,"/").replace(/~0/g,"~")))value=value&&typeof value=="object"&&Object.hasOwn(value,key)?value[key]:void 0}catch{return}return value&&typeof value=="object"?value:void 0}function rootRefChain(input){let chain=[];for(let schema=input;schema!==void 0;schema=lookupLocalRef(input,schema.$ref)){if(chain.includes(schema))return;if(chain.push(schema),schema.$ref===void 0)return chain}}function mcpToolNameSegment(name){return name.toLowerCase().replace(/-/g,"_")}function toolTitle(name){let words=name.replace(/([a-z0-9])([A-Z])/g,"$1 $2").replace(/[-_.\s]+/g," ").trim().toLowerCase();return words.charAt(0).toUpperCase()+words.slice(1)}var viewAnnotations={readOnlyHint:!0,destructiveHint:!1};function procedureAnnotations(p){let readOnlyHint=p.mcp?.readOnlyHint??!1;return{readOnlyHint,destructiveHint:!readOnlyHint&&("ref"in p.handler||overwrites(p.handler.sql.stmts)),...p.mcp}}var overwrites=node=>!!node&&typeof node=="object"&&("DeleteStmt"in node||"UpdateStmt"in node||node.onConflictClause?.action==="ONCONFLICT_UPDATE"||Object.values(node).some(overwrites));function mcpTools(plan,surface,locale="en"){let text=t=>resolveLocalizedText(t,locale,"en")??void 0,tools=new Map,add=tool=>{let prior=tools.get(tool.name);if(prior&&(prior.kind!==tool.kind||prior.source!==tool.source))throw new TypeError(`MCP tool '${tool.name}' comes from both '${prior.source}' and '${tool.source}'.`);tools.set(tool.name,tool)};for(let t of Object.values(plan.triggers)){if(t.source.kind!=="mcp"||t.source.surface!==surface)continue;let p=plan.procedures[t.procedure],title=text(p.title);add({name:mcpToolNameSegment(t.procedure),kind:"procedure",source:t.procedure,title:title??toolTitle(t.procedure),description:text(p.description)??title??t.procedure,inputSchema:p.input,...p.output.type==="object"?{outputSchema:p.output}:{},annotations:procedureAnnotations(p),requires:p.requires})}for(let[name,v]of Object.entries(plan.views)){if(v.surface!==surface)continue;let chain=(v.input?rootRefChain(v.input)??[v.input]:[]).reverse(),props=Object.assign({},...chain.map(s=>s.properties??{})),required=[...new Set(chain.flatMap(s=>s.required??[]))];if("limit"in props||"cursor"in props)throw new TypeError(`View '${name}' declares an input named limit or cursor; the MCP tool reserves both for paging.`);let rules=chain.map(s=>Object.fromEntries(INPUT_RULE_KEYWORDS.flatMap(k=>s[k]===void 0?[]:[[k,s[k]]]))).filter(r=>Object.keys(r).length),defs=v.input?referencedDefs(v.input,props):{},title=text(v.title);add({name:mcpToolNameSegment(name),kind:"view",source:name,title:title??toolTitle(name),description:text(v.description)??title??name,inputSchema:{type:"object",properties:{...props,limit:{type:"integer",minimum:1,maximum:500},cursor:{type:"string"}},...required.length?{required}:{},...rules.length>1?{allOf:rules}:rules[0],...Object.keys(defs).length?{$defs:defs}:{}},annotations:viewAnnotations,requires:v.requires})}return[...tools.values()]}function referencedDefs(root,node,found={}){if(!node||typeof node!="object")return found;let ref=node.$ref,[head,segment]=typeof ref=="string"&&ref.startsWith("#/")?decoded(ref.slice(2)).split("/"):[],key=head==="$defs"?segment?.replace(/~1/g,"/").replace(/~0/g,"~"):void 0;key!==void 0&&!Object.hasOwn(found,key)&&root.$defs&&Object.hasOwn(root.$defs,key)&&(found[key]=root.$defs[key],referencedDefs(root,found[key],found));for(let child of Object.values(node))referencedDefs(root,child,found);return found}var decoded=pointer=>{try{return decodeURIComponent(pointer)}catch{return""}};var publishingTransitions=[["publish",["draft"],"published"],["unpublish",["published","archived"],"draft"],["archive",["draft","published"],"archived"]];var publishingInput={type:"object",properties:{id:{type:"string",minLength:1},expectedVersion:{type:"integer",minimum:1}},required:["id","expectedVersion"],additionalProperties:!1},policyText=policies=>policies.map(p=>`'${p.name}' takes ${p.required.map(slot=>slot.split(",").map(mime=>`${mime} up to ${p.maxBytes[mime]} bytes`).join(" or ")).join(", plus ")}`).join("; ");function mediaDefinitions(policies=[]){let define=(name,description,inputSchema,action,readOnlyHint=!1,meta)=>({name,description,inputSchema,action,annotations:{readOnlyHint,destructiveHint:action==="delete",openWorldHint:!0},...meta?{meta}:{}}),string={type:"string"},texts={alt:{type:"string",maxLength:1e3},caption:{type:"string",maxLength:1e3}},input=(properties,required=[])=>({type:"object",properties,required,additionalProperties:!1}),variants={type:"array",minItems:1,items:input({mimeType:string,byteSize:{type:"integer",minimum:1},role:{type:"string",enum:["primary","alternate","fallback"]}},["mimeType","byteSize","role"])},purpose=policies.length?{type:"string",enum:policies.map(p=>p.name),description:`Accepted variants and byte caps: ${policyText(policies)}.`}:string,paging={limit:{type:"integer",minimum:1,maximum:500},cursor:string,search:string},old=text=>`Deprecated, removed in 0.3: ${text}`;return[define("upload_media","Upload one image to the media library in two calls; bytes never pass through MCP and file content is never accepted as base64. 1. Call with purpose, filename and variants (one per slot the purpose takes, exactly one primary; optimize the image locally first). The result has an uploadId and the upload targets, which expire in 15 minutes and serve this one upload. PUT each variant's bytes to its target url with its headers. 2. Call with { uploadId } to commit, and store the returned asset id in fields that reference media_assets.id, never an upload URL. An unknown or expired uploadId needs a new first call. Called with only a purpose, it returns that purpose's accepted types and byte caps; a host that shows MCP Apps opens an upload card where the person picks the file, and the card commits it and reports the asset id. With purpose and sourceUrl (a public https image) or file (an attachment), the server fetches the image and commits it in one call; that takes a purpose with a single variant slot.",input({purpose,filename:{type:"string",maxLength:255},variants,...texts,uploadId:{type:"string",minLength:1,maxLength:100},sourceUrl:{type:"string",format:"uri",maxLength:2048},file:{type:"object",properties:{download_url:{type:"string",maxLength:4096},file_id:{type:"string",maxLength:255}},required:["download_url","file_id"]}}),"upload",!1,{"openai/fileParams":["file"]}),define("find_media","Find committed image assets with their permanent variants and alt/caption: by ids (ids not found are listed in missing), or newest first, where search matches id, alt or caption; reuse only a returned cursor.",input({ids:{type:"array",minItems:1,maxItems:100,items:{type:"string",minLength:1}},...paging}),"find",!0),define("update_media","Update a committed asset's alt or caption; omitted fields remain unchanged and empty strings clear text.",input({id:string,...texts},["id"]),"update"),define("delete_media","Delete an asset's objects and library record. Check application references first. A partial failure can be retried using the same id.",input({id:string},["id"]),"delete"),define("get_media_upload_policy",old("call upload_media with only a purpose. Read declared image purpose MIME slots and byte caps before preparing variants or requesting an upload. Optimization runs locally, never in the host."),input({}),"policy",!0),define("create_media_upload",old("use upload_media. Create short-lived PUT capabilities for image variants. Read site media purpose policy first; optimize images locally. Obtain attachment bytes yourself, then PUT each variant with its requiredHeaders and call commit_media_upload. Never store upload URLs as published images or ask the user to run terminal commands."),input({filename:{type:"string",maxLength:255},purpose:string,variants,...texts},["filename","purpose","variants"]),"create"),define("commit_media_upload",old("call upload_media with { uploadId }. Verify all uploaded variants and commit one permanent media asset. Store the returned asset id in fields referencing media_assets.id, never a temporary upload URL. Unknown or expired upload groups require a new create_media_upload."),input({uploadGroupId:string,...texts},["uploadGroupId"]),"commit"),define("list_media_assets",old("use find_media. List committed image assets, newest first, with their permanent variants and alt/caption. Search matches id, alt or caption; reuse only a returned cursor."),input(paging),"list",!0),define("get_media_asset",old("use find_media with ids. Read one committed media asset and its permanent variant URLs."),input({id:string},["id"]),"get",!0),define("update_media_asset",old("use update_media. Update committed asset alt or caption; omitted fields remain unchanged and empty strings clear text."),input({id:string,...texts},["id"]),"update"),define("delete_media_asset",old("use delete_media. Delete an asset's objects and library record. Check application references first. A partial failure can be retried using the same id."),input({id:string},["id"]),"delete")]}var ADMIN_API_BASE="/admin/api",pathTemplate=path=>path.replace(/\{([^}]+)\}/g,":$1"),routePath=template=>template.split("/").map(segment=>segment.startsWith(":")?`{${segment.slice(1)}}`:segment).join("/");function routeSource(template,base="",suffix=!1){let keys=[],path=template.split("/").map(segment=>{let rest=suffix?/^\{([a-zA-Z]\w*)\*\}$/.exec(segment):null,one=/^\{([^}]+)\}$/.exec(segment);return rest||one?(keys.push((rest??one)[1]),rest?"(.*)":"([^/]+)"):encodeURIComponent(segment).replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}).join("/");return{source:`^${base}${path}/?$`,keys}}function canonicalPath(pathname){try{return pathname.split("/").map(segment=>encodeURIComponent(decodeURIComponent(segment))).join("/")}catch{return}}var isParameter=segment=>/^\{[^}]+\}$/.test(segment);function routeOrder(a,b){let x=a.split("/"),y=b.split("/"),count=segments=>segments.filter(isParameter).length;if(count(x)!==count(y))return count(x)-count(y);for(let i=0;i<Math.min(x.length,y.length);i++)if(isParameter(x[i])!==isParameter(y[i]))return isParameter(x[i])?1:-1;return a<b?-1:a>b?1:0}function inspectSql(value){let schemas=new Set,readSchemas=new Set,writes=[],readOnly=!0,walk=v=>{if(!v||typeof v!="object")return;let n=v;n.RangeVar?.mantle==="table"&&(schemas.add(n.RangeVar.relname.toLowerCase()),readSchemas.add(n.RangeVar.relname.toLowerCase()));for(let kind of["InsertStmt","UpdateStmt","DeleteStmt"])n[kind]&&(readOnly=!1,n[kind].relation?.relname&&writes.push({schema:n[kind].relation.relname.toLowerCase(),operation:kind==="InsertStmt"?"create":kind==="UpdateStmt"?"update":"delete"}),n[kind].relation?.relname&&(schemas.add(n[kind].relation.relname.toLowerCase()),kind!=="InsertStmt"&&readSchemas.add(n[kind].relation.relname.toLowerCase())));Object.values(n).forEach(walk)};return walk(value),{schemas:[...schemas].sort(),readSchemas:[...readSchemas].sort(),readOnly,writes}}function mergeRestrict(policies,conjunctive){return Object.fromEntries([...new Set(policies.flatMap(p=>Object.keys(p)))].sort().map(schema=>{let rows=policies.map(p=>p[schema]??{terms:[]}),terms=[...new Map(rows.flatMap(p=>p.terms).map(t=>[canonical(t),t])).values()].filter(t=>conjunctive||rows.every(p=>p.terms.some(v=>canonical(v)===canonical(t)))),owner=rows.find(p=>p.owner)?.owner;return[schema,{...owner&&(conjunctive||rows.every(p=>p.owner===owner))?{owner}:{},terms}]}))}function inspectBehavior(plan,ir,opaque,requires,restrict,writes){let inspected=inspectSql(ir);writes&&(inspected.writes=[...writes],inspected.schemas.push(...writes.map(w=>w.schema)),inspected.readSchemas.push(...writes.filter(w=>w.operation!=="create").map(w=>w.schema)));let dependencies=[],policies=[restrict??{}],procedures=new Set,hooks=new Set,visit=name=>{if(procedures.has(name))return;procedures.add(name);let p=plan.procedures[name];if(!p)throw new TypeError(`Unknown dependency '${name}'.`);dependencies.push({procedure:name,declaration:p}),policies.push(p.restrict??{}),opaque||="ref"in p.handler;let g=inspectSql("sql"in p.handler?p.handler.sql.stmts:void 0);inspected.schemas.push(...g.schemas),inspected.readSchemas.push(...g.readSchemas),inspected.writes.push(...g.writes),inspected.readOnly&&=g.readOnly,p.requires?.guard&&visit(p.requires.guard.procedure)};for(requires?.guard&&visit(requires.guard.procedure);;){let before=hooks.size;for(let[id,t]of Object.entries(plan.triggers).sort(([a],[b])=>a<b?-1:a>b?1:0)){if(t.source.kind!=="lifecycle"||hooks.has(id))continue;let source=t.source;!opaque&&!inspected.writes.some(w=>w.schema===source.schema.toLowerCase()&&source.on.some(on=>on.endsWith(`_${w.operation}`)))||(hooks.add(id),dependencies.push({trigger:id,source}),visit(t.procedure))}if(hooks.size===before)break}let related=names=>{let all=new Set(opaque?Object.keys(plan.schemas):names);for(let name of all){let parent=plan.schemas[name]?.translates?.parent.toLowerCase();parent&&all.add(parent)}return[...all].sort()};return{...inspected,schemas:related(inspected.schemas),readSchemas:related(inspected.readSchemas),opaque,restrict:mergeRestrict(policies,!0),dependencies}}var objectInput={type:"object",properties:{}},adminRoutes=["GET /me","GET /site","GET /collections","GET /views-manifest","GET /operations","GET /webmcp","POST /webmcp/:tool","GET /bootstrap","GET /views/:name/export","GET /views/:name","GET /view-batch","GET /developer/views/:name","POST /operations/:name","GET /developer-console","GET /collections/:name/statistics","GET /entries","GET /entries/export","GET /entries/:id","POST /entries","PATCH /entries/:id","POST /entries/:id/publish","POST /entries/:id/unpublish","POST /entries/:id/archive","DELETE /entries/:id","GET /site-settings","PATCH /site-settings","POST /media/uploads","POST /media/uploads/:groupId/commit","GET /media","GET /media/:id","PATCH /media/:id","DELETE /media/:id","GET /admin","GET /members","PATCH /admin/:id/role","POST /admin/invitations","DELETE /admin/invitations/:id"];function targetDefinitions(plan){let out=[],tools=[...mcpTools(plan,"public"),...mcpTools(plan,"admin")];for(let[kind,entries]of[["view",plan.views],["procedure",plan.procedures]])for(let[name,p]of Object.entries(entries).sort(([a],[b])=>a<b?-1:a>b?1:0)){let view=kind==="view"?plan.views[name]:void 0,proc=kind==="procedure"?plan.procedures[name]:void 0,ir=view?.stmts??(proc&&"sql"in proc.handler?proc.handler.sql.stmts:void 0),inspected=inspectBehavior(plan,ir,!!proc&&"ref"in proc.handler,p.requires,p.restrict),tool=tools.find(t=>t.kind===kind&&t.source===name),title=resolveLocalizedText(p.title,"en","en")??name,triggers=Object.entries(plan.triggers).filter(([,t])=>kind==="procedure"&&t.procedure===name).sort(([a],[b])=>a<b?-1:a>b?1:0).map(([id,t])=>({id,source:t.source})),surfaces=[...new Set([...view?[view.surface,"admin",...view.surface==="public"?["rest"]:[]]:[],...triggers.map(t=>t.source.kind==="mcp"?t.source.surface:t.source.kind==="http"?"rest":t.source.kind)])].sort();surfaces.length||surfaces.push("internal"),out.push({id:`${kind}:${name}`,name:tool?.name??mcpToolNameSegment(name),kind,surface:view?.surface??(surfaces.includes("public")||surfaces.includes("rest")?"public":surfaces.includes("admin")?"admin":"internal"),surfaces,triggers,title,description:resolveLocalizedText(p.description,"en","en")??title,inputSchema:tool?.inputSchema??view?.input??proc?.input??objectInput,annotations:tool?.annotations??{},requires:p.requires,restrict:inspected.restrict,schemas:inspected.schemas,readSchemas:inspected.readSchemas,readOnly:!inspected.opaque&&inspected.readOnly,opaque:inspected.opaque,trustedOnly:!!proc?.trustedOnly,variants:[],behavior:{declaration:p,dependencies:inspected.dependencies}})}let addRoute=(id,dependencies,inputSchema=objectInput)=>{let admin=id.startsWith("admin:");out.push({id,name:id.replace(/[^a-zA-Z0-9_-]/g,"_"),title:id,description:id,kind:admin?"admin":"rest",surface:admin?"admin":"public",surfaces:[admin?"admin":"rest"],triggers:dependencies.flatMap(d=>d.triggers),inputSchema,annotations:{},requires:admin?{auth:{all:["ctx.admin"]}}:dependencies[0]?.requires,restrict:mergeRestrict(dependencies.map(d=>d.restrict),!1),schemas:[...new Set(dependencies.flatMap(d=>d.schemas))].sort(),readSchemas:[...new Set(dependencies.flatMap(d=>d.readSchemas))].sort(),readOnly:dependencies.length>0&&dependencies.every(d=>d.readOnly)&&(!admin||id.startsWith("admin:GET ")||id==="admin:POST /operations/:name"||id==="admin:POST /webmcp/:tool"),opaque:dependencies.some(d=>d.opaque),trustedOnly:dependencies.some(d=>d.trustedOnly),variants:dependencies,behavior:dependencies.map(d=>({id:d.id,behavior:d.behavior,surfaces:d.surfaces,triggers:d.triggers,input:d.inputSchema,requires:d.requires,restrict:d.restrict,trustedOnly:d.trustedOnly}))})},declarations=[...out],adminOperations=new Set(mcpTools(plan,"admin").filter(t=>t.kind==="procedure").map(t=>`procedure:${t.source}`));for(let d of declarations)d.kind==="view"&&d.surfaces.includes("public")&&addRoute(`rest:GET /api/views/${d.id.slice(5)}`,[d],d.inputSchema);for(let[,t]of Object.entries(plan.triggers).sort(([a],[b])=>a<b?-1:a>b?1:0))if(t.source.kind==="http"){let d=declarations.find(d2=>d2.id===`procedure:${t.procedure}`);addRoute(`rest:${t.source.method} ${pathTemplate(t.source.path)}`,[d],d.inputSchema)}let generated=[];for(let[schema,s]of Object.entries(plan.schemas).sort(([a],[b])=>a<b?-1:a>b?1:0))if(s.publishing)for(let[verb,from,to]of publishingTransitions){addRoute(`procedure:${s.name}.${verb}`,[],publishingInput);let d=out.at(-1),inspected=inspectBehavior(plan,void 0,!1,void 0,void 0,[{schema,operation:verb==="publish"?"publish":"update"}]);Object.assign(d,{kind:"procedure",name:mcpToolNameSegment(`${verb}_${s.name}`),surface:"admin",surfaces:["admin"],requires:{auth:{all:["ctx.admin"]}},schemas:[...new Set([schema,...inspected.schemas])].sort(),readSchemas:[...new Set([schema,...inspected.readSchemas])].sort(),opaque:inspected.opaque,behavior:{schema,verb,from,to,dependencies:inspected.dependencies}}),generated.push(d)}for(let tool of mediaDefinitions()){addRoute(`admin:tool:${tool.name}`,[],tool.inputSchema);let d=out.at(-1);Object.assign(d,{name:tool.name,readOnly:tool.annotations.readOnlyHint,opaque:!tool.annotations.readOnlyHint,annotations:tool.annotations,behavior:{tool,schemas:plan.schemas}}),generated.push(d)}for(let route2 of adminRoutes){let deps=route2.includes("/views/:")||route2==="GET /view-batch"?declarations.filter(d2=>d2.kind==="view"&&(route2.includes("/developer/")||plan.views[d2.id.slice(5)]?.surface==="admin")):route2.includes("/operations/:")?declarations.filter(d2=>adminOperations.has(d2.id)):route2==="POST /webmcp/:tool"?declarations.filter(d2=>mcpTools(plan,"admin").some(t=>`${t.kind}:${t.source}`===d2.id)).concat(generated):[],generic=route2.includes("/entries")||route2.includes("/statistics")||route2==="GET /bootstrap";addRoute(`admin:${route2}`,deps,deps.length&&route2!=="GET /view-batch"?{...objectInput,anyOf:deps.map(d2=>({type:"object",properties:{[route2.includes("/webmcp/")?"tool":"name"]:{type:"string",const:route2.includes("/webmcp/")?d2.name:d2.id.slice(d2.kind==="view"?5:10)}}}))}:objectInput);let d=out.at(-1);if(generic){let operation=route2==="POST /entries"?"create":route2==="DELETE /entries/:id"?"delete":route2.startsWith("GET ")?void 0:route2.endsWith("/publish")?"publish":"update",inspected=inspectBehavior(plan,void 0,!1,void 0,void 0,operation?Object.keys(plan.schemas).map(schema=>({schema,operation})):[]);d.schemas=Object.keys(plan.schemas).sort(),d.readSchemas=d.schemas,d.opaque=inspected.opaque,d.behavior={schemas:plan.schemas,dependencies:inspected.dependencies}}}return[...new Map(out.map(d=>[d.id,d])).values()].sort((a,b)=>a.id<b.id?-1:a.id>b.id?1:0)}var tables=new WeakMap,route=/^(rest|admin):([A-Z]+) (\/.*)$/,normalized=new Set(["DELETE","GET","HEAD","OPTIONS","POST","PUT"]);function compile(describe){let table={rest:[],admin:[],tools:[]};for(let{name,_meta:meta}of describe.targets){let[,surface,method,template]=route.exec(meta.id)??[];if(surface==="rest")table.rest.push({method,pattern:new RegExp(routeSource(routePath(template)).source),view:meta.variants[0]?.id.startsWith("view:")===!0,meta});else if(surface==="admin")table.admin.push({method,pattern:new RegExp(routeSource(routePath(template),ADMIN_API_BASE).source),meta});else{let s=meta.surfaces,endpoints=meta.id.startsWith("admin:tool:")?["admin"]:meta.id.startsWith("view:")?s.includes("public")?["public"]:s.includes("internal")?[]:["admin"]:meta.id.startsWith("procedure:")?s.filter(x=>x==="public"||x==="admin"):[];endpoints.length&&table.tools.push({name,endpoints,meta})}}let order=id=>adminRoutes.indexOf(id.slice(6))>>>0,path=id=>routePath(id.slice(id.indexOf(" ")+1));return table.rest.sort((a,b)=>routeOrder(path(a.meta.id),path(b.meta.id))),table.admin.sort((a,b)=>order(a.meta.id)-order(b.meta.id)),table}function matchTarget(describe,request){let table=tables.get(describe);if(table||tables.set(describe,table=compile(describe)),request.surface==="mcp"){let path2=request.path?.replace(/\/+$/,""),endpoint=path2===void 0?void 0:path2==="/mcp"?"public":path2==="/mcp/admin"?"admin":null,found=table.tools.filter(t=>t.name===request.tool&&(endpoint===void 0||t.endpoints.includes(endpoint)));return endpoint!==null&&found.length&&found.every(t=>t.meta.id===found[0].meta.id)?found[0].meta:null}let method=normalized.has(request.method.toUpperCase())?request.method.toUpperCase():request.method,path=canonicalPath(request.path);return path===void 0?null:request.surface==="admin"?request.path!==ADMIN_API_BASE&&!request.path.startsWith(ADMIN_API_BASE+"/")?null:table.admin.find(r=>r.method===method&&r.pattern.test(path))?.meta??null:table.rest.find(r=>r.pattern.test(path)&&(r.method===method||method==="HEAD"&&r.method==="GET"&&r.view))?.meta??null}export{canonical,targetDefinitions,matchTarget};
@@ -1 +1 @@
1
- import{canonical,matchTarget,targetDefinitions}from"./chunk-CW7X5Q5T.js";async function hash(value){let digest=await crypto.subtle.digest("SHA-256",new TextEncoder().encode(canonical(value)));return[...new Uint8Array(digest)].map(b=>b.toString(16).padStart(2,"0")).join("")}function hashDefinition(plan,d){return hash({id:d.id,surfaces:d.surfaces,triggers:d.triggers,behavior:d.behavior,input:d.inputSchema,requires:d.requires,restrict:d.restrict,dialect:plan.dialect,schemas:Object.fromEntries(d.schemas.map(name=>[name,plan.schemas[name]])),userFields:plan.access?.userFields,readOnly:d.readOnly,opaque:d.opaque,trustedOnly:d.trustedOnly})}async function targetHash(plan,targetId){let target=targetDefinitions(plan).find(d=>d.id===targetId);if(!target)throw new TypeError(`Unknown target '${targetId}'.`);return hashDefinition(plan,target)}function requirements(d,surface){let all=d.requires?.auth?.all??[];return{all:surface==="admin"&&!all.includes("ctx.admin")?["ctx.admin",...all]:all,...d.requires?.guard?{guard:d.requires.guard.procedure}:{}}}async function describeApp(plan){let targets=await Promise.all(targetDefinitions(plan).map(async d=>{let scope=Object.fromEntries(d.schemas.map(name=>{let s=plan.schemas[name];return[name,{...s.scope?{owner:s.scope}:{},terms:s.userScopes??[]}]}));return{name:d.name,title:d.title,description:d.description,inputSchema:d.inputSchema,annotations:{...d.annotations,readOnlyHint:d.readOnly},_meta:{surfaces:d.surfaces,triggers:d.triggers,id:d.id,targetHash:await hashDefinition(plan,d),readOnly:d.readOnly,scope,restrict:d.restrict,opaque:d.opaque,unscopedReads:d.readSchemas.some(name=>!plan.schemas[name]?.scope&&!plan.schemas[name]?.userScopes?.length),trustedOnly:d.trustedOnly,variants:d.variants.map(v=>({id:v.id,restrict:v.restrict,requires:requirements(v,d.surface),inputSchema:v.inputSchema,readOnly:v.readOnly,trustedOnly:v.trustedOnly,opaque:v.opaque,unscopedReads:v.readSchemas.some(name=>!plan.schemas[name]?.scope&&!plan.schemas[name]?.userScopes?.length),scope:Object.fromEntries(v.schemas.map(name=>{let s=plan.schemas[name];return[name,{...s.scope?{owner:s.scope}:{},terms:s.userScopes??[]}]}))})),requires:requirements(d,d.surface)}}}));return JSON.parse(canonical({schemaVersion:1,access:plan.access?.userFields??{},targets}))}var securityFlags=["readOnly","trustedOnly","opaque","unscopedReads"],flagDirection=(field,value)=>(field==="readOnly"||field==="trustedOnly"?!value:value)?"widened":"narrowed",inclusion=(before,after)=>before.some(t=>!after.includes(t))?"widened":after.some(t=>!before.includes(t))?"narrowed":"changed",exposureDirection=(before,after)=>after.some(t=>!before.includes(t)&&t!==canonical("internal"))?"widened":before.some(t=>!after.includes(t)&&t!==canonical("internal"))?"narrowed":"changed",combine=directions=>directions.includes("widened")?"widened":directions.length&&directions.every(d=>d==="narrowed")?"narrowed":"changed",terms=p=>[...p.owner?[canonical({owner:p.owner})]:[],...p.terms.map(canonical)];function policyDirection(before,after,scope){let directions=[];for(let schema of new Set([...Object.keys(before),...Object.keys(after)])){let x=before[schema],y=after[schema];canonical(x)!==canonical(y)&&directions.push(scope&&(!x||!y)?x?"narrowed":"widened":inclusion(x?terms(x):[],y?terms(y):[]))}return combine(directions)}function requiresDirection(x,y){let directions=[];return canonical(x.all)!==canonical(y.all)&&directions.push(inclusion(x.all.map(canonical),y.all.map(canonical))),x.guard!==y.guard&&directions.push(y.guard?x.guard?"changed":"narrowed":"widened"),combine(directions)}function diffApp(a,b){if(a.schemaVersion!==1||b.schemaVersion!==1)throw new TypeError("Unsupported describe schemaVersion.");let changes=[],old=new Map(a.targets.map(t=>[t._meta.id,t])),next=new Map(b.targets.map(t=>[t._meta.id,t]));for(let id of[...new Set([...old.keys(),...next.keys()])].sort()){let before=old.get(id),after=next.get(id);if(!before||!after){changes.push({id,field:"target",direction:before?"narrowed":"widened"});continue}let x=before._meta,y=after._meta,start=changes.length,record=(field,left,right,direction)=>{canonical(left)!==canonical(right)&&changes.push({id,field,direction,before:left,after:right})};record("scope",x.scope,y.scope,policyDirection(x.scope,y.scope,!0)),record("restrict",x.restrict,y.restrict,policyDirection(x.restrict,y.restrict,!1)),record("requires",x.requires,y.requires,requiresDirection(x.requires,y.requires));for(let field of["surfaces","triggers"])record(field,x[field],y[field],exposureDirection(x[field].map(canonical),y[field].map(canonical)));for(let field of securityFlags)record(field,x[field],y[field],flagDirection(field,y[field]));record("inputSchema",before.inputSchema,after.inputSchema,"changed");let directions=[],variants=new Map(x.variants.map(v=>[v.id,v])),added=new Map(y.variants.map(v=>[v.id,v]));for(let name of new Set([...variants.keys(),...added.keys()])){let left=variants.get(name),right=added.get(name);if(canonical(left)!==canonical(right))if(!left||!right)directions.push(left?"narrowed":"widened");else{canonical(left.scope)!==canonical(right.scope)&&directions.push(policyDirection(left.scope,right.scope,!0)),canonical(left.restrict)!==canonical(right.restrict)&&directions.push(policyDirection(left.restrict,right.restrict,!1)),canonical(left.requires)!==canonical(right.requires)&&directions.push(requiresDirection(left.requires,right.requires)),canonical(left.inputSchema)!==canonical(right.inputSchema)&&directions.push("changed");for(let field of securityFlags)left[field]!==right[field]&&directions.push(flagDirection(field,right[field]))}}record("variants",x.variants,y.variants,combine(directions)),changes.length===start&&x.targetHash!==y.targetHash&&changes.push({id,field:"behavior",direction:"changed"})}return changes}export{describeApp,diffApp,matchTarget,targetHash};
1
+ import{canonical,matchTarget,targetDefinitions}from"./chunk-GMUORMOX.js";async function hash(value){let digest=await crypto.subtle.digest("SHA-256",new TextEncoder().encode(canonical(value)));return[...new Uint8Array(digest)].map(b=>b.toString(16).padStart(2,"0")).join("")}function hashDefinition(plan,d){return hash({id:d.id,surfaces:d.surfaces,triggers:d.triggers,behavior:d.behavior,input:d.inputSchema,requires:d.requires,restrict:d.restrict,dialect:plan.dialect,schemas:Object.fromEntries(d.schemas.map(name=>[name,plan.schemas[name]])),userFields:plan.access?.userFields,readOnly:d.readOnly,opaque:d.opaque,trustedOnly:d.trustedOnly})}async function targetHash(plan,targetId){let target=targetDefinitions(plan).find(d=>d.id===targetId);if(!target)throw new TypeError(`Unknown target '${targetId}'.`);return hashDefinition(plan,target)}function requirements(d,surface){let all=d.requires?.auth?.all??[];return{all:surface==="admin"&&!all.includes("ctx.admin")?["ctx.admin",...all]:all,...d.requires?.guard?{guard:d.requires.guard.procedure}:{}}}async function describeApp(plan){let targets=await Promise.all(targetDefinitions(plan).map(async d=>{let scope=Object.fromEntries(d.schemas.map(name=>{let s=plan.schemas[name];return[name,{...s.scope?{owner:s.scope}:{},terms:s.userScopes??[]}]}));return{name:d.name,title:d.title,description:d.description,inputSchema:d.inputSchema,annotations:{...d.annotations,readOnlyHint:d.readOnly},_meta:{surfaces:d.surfaces,triggers:d.triggers,id:d.id,targetHash:await hashDefinition(plan,d),readOnly:d.readOnly,scope,restrict:d.restrict,opaque:d.opaque,unscopedReads:d.readSchemas.some(name=>!plan.schemas[name]?.scope&&!plan.schemas[name]?.userScopes?.length),trustedOnly:d.trustedOnly,variants:d.variants.map(v=>({id:v.id,restrict:v.restrict,requires:requirements(v,d.surface),inputSchema:v.inputSchema,readOnly:v.readOnly,trustedOnly:v.trustedOnly,opaque:v.opaque,unscopedReads:v.readSchemas.some(name=>!plan.schemas[name]?.scope&&!plan.schemas[name]?.userScopes?.length),scope:Object.fromEntries(v.schemas.map(name=>{let s=plan.schemas[name];return[name,{...s.scope?{owner:s.scope}:{},terms:s.userScopes??[]}]}))})),requires:requirements(d,d.surface)}}}));return JSON.parse(canonical({schemaVersion:1,access:plan.access?.userFields??{},targets}))}var securityFlags=["readOnly","trustedOnly","opaque","unscopedReads"],flagDirection=(field,value)=>(field==="readOnly"||field==="trustedOnly"?!value:value)?"widened":"narrowed",inclusion=(before,after)=>before.some(t=>!after.includes(t))?"widened":after.some(t=>!before.includes(t))?"narrowed":"changed",exposureDirection=(before,after)=>after.some(t=>!before.includes(t)&&t!==canonical("internal"))?"widened":before.some(t=>!after.includes(t)&&t!==canonical("internal"))?"narrowed":"changed",combine=directions=>directions.includes("widened")?"widened":directions.length&&directions.every(d=>d==="narrowed")?"narrowed":"changed",terms=p=>[...p.owner?[canonical({owner:p.owner})]:[],...p.terms.map(canonical)];function policyDirection(before,after,scope){let directions=[];for(let schema of new Set([...Object.keys(before),...Object.keys(after)])){let x=before[schema],y=after[schema];canonical(x)!==canonical(y)&&directions.push(scope&&(!x||!y)?x?"narrowed":"widened":inclusion(x?terms(x):[],y?terms(y):[]))}return combine(directions)}function requiresDirection(x,y){let directions=[];return canonical(x.all)!==canonical(y.all)&&directions.push(inclusion(x.all.map(canonical),y.all.map(canonical))),x.guard!==y.guard&&directions.push(y.guard?x.guard?"changed":"narrowed":"widened"),combine(directions)}function diffApp(a,b){if(a.schemaVersion!==1||b.schemaVersion!==1)throw new TypeError("Unsupported describe schemaVersion.");let changes=[],old=new Map(a.targets.map(t=>[t._meta.id,t])),next=new Map(b.targets.map(t=>[t._meta.id,t]));for(let id of[...new Set([...old.keys(),...next.keys()])].sort()){let before=old.get(id),after=next.get(id);if(!before||!after){changes.push({id,field:"target",direction:before?"narrowed":"widened"});continue}let x=before._meta,y=after._meta,start=changes.length,record=(field,left,right,direction)=>{canonical(left)!==canonical(right)&&changes.push({id,field,direction,before:left,after:right})};record("scope",x.scope,y.scope,policyDirection(x.scope,y.scope,!0)),record("restrict",x.restrict,y.restrict,policyDirection(x.restrict,y.restrict,!1)),record("requires",x.requires,y.requires,requiresDirection(x.requires,y.requires));for(let field of["surfaces","triggers"])record(field,x[field],y[field],exposureDirection(x[field].map(canonical),y[field].map(canonical)));for(let field of securityFlags)record(field,x[field],y[field],flagDirection(field,y[field]));record("inputSchema",before.inputSchema,after.inputSchema,"changed");let directions=[],variants=new Map(x.variants.map(v=>[v.id,v])),added=new Map(y.variants.map(v=>[v.id,v]));for(let name of new Set([...variants.keys(),...added.keys()])){let left=variants.get(name),right=added.get(name);if(canonical(left)!==canonical(right))if(!left||!right)directions.push(left?"narrowed":"widened");else{canonical(left.scope)!==canonical(right.scope)&&directions.push(policyDirection(left.scope,right.scope,!0)),canonical(left.restrict)!==canonical(right.restrict)&&directions.push(policyDirection(left.restrict,right.restrict,!1)),canonical(left.requires)!==canonical(right.requires)&&directions.push(requiresDirection(left.requires,right.requires)),canonical(left.inputSchema)!==canonical(right.inputSchema)&&directions.push("changed");for(let field of securityFlags)left[field]!==right[field]&&directions.push(flagDirection(field,right[field]))}}record("variants",x.variants,y.variants,combine(directions)),changes.length===start&&x.targetHash!==y.targetHash&&changes.push({id,field:"behavior",direction:"changed"})}return changes}export{describeApp,diffApp,matchTarget,targetHash};
@@ -1 +1 @@
1
- import{matchTarget}from"./chunk-CW7X5Q5T.js";export{matchTarget};
1
+ import{matchTarget}from"./chunk-GMUORMOX.js";export{matchTarget};
@@ -1,18 +1,33 @@
1
1
  export const publishingTransitions = [["publish", ["draft"], "published"], ["unpublish", ["published", "archived"], "draft"], ["archive", ["draft", "published"], "archived"]];
2
2
  export const publishingAnnotations = (verb) => ({ readOnlyHint: false, destructiveHint: verb === "archive" });
3
3
  export const publishingInput = { type: "object", properties: { id: { type: "string", minLength: 1 }, expectedVersion: { type: "integer", minimum: 1 } }, required: ["id", "expectedVersion"], additionalProperties: false };
4
- export function mediaDefinitions() {
5
- const define = (name, description, inputSchema, action, readOnlyHint = false) => ({ name, description, inputSchema, action, annotations: { readOnlyHint, destructiveHint: action === "delete", openWorldHint: true } });
4
+ const policyText = (policies) => policies.map(p => `'${p.name}' takes ${p.required.map(slot => slot.split(",").map(mime => `${mime} up to ${p.maxBytes[mime]} bytes`).join(" or ")).join(", plus ")}`).join("; ");
5
+ export function mediaDefinitions(policies = []) {
6
+ const define = (name, description, inputSchema, action, readOnlyHint = false, meta) => ({ name, description, inputSchema, action, annotations: { readOnlyHint, destructiveHint: action === "delete", openWorldHint: true }, ...(meta ? { meta } : {}) });
6
7
  const string = { type: "string" };
7
8
  const texts = { alt: { type: "string", maxLength: 1000 }, caption: { type: "string", maxLength: 1000 } };
8
9
  const input = (properties, required = []) => ({ type: "object", properties, required, additionalProperties: false });
10
+ const variants = { type: "array", minItems: 1, items: input({ mimeType: string, byteSize: { type: "integer", minimum: 1 }, role: { type: "string", enum: ["primary", "alternate", "fallback"] } }, ["mimeType", "byteSize", "role"]) };
11
+ const purpose = policies.length ? { type: "string", enum: policies.map(p => p.name), description: `Accepted variants and byte caps: ${policyText(policies)}.` } : string;
12
+ const paging = { limit: { type: "integer", minimum: 1, maximum: 500 }, cursor: string, search: string };
13
+ const old = (text) => `Deprecated, removed in 0.3: ${text}`;
9
14
  return [
10
- define("get_media_upload_policy", "Read declared image purpose MIME slots and byte caps before preparing variants or requesting an upload. Optimization runs locally, never in the host.", input({}), "policy", true),
11
- define("create_media_upload", "Create short-lived PUT capabilities for image variants. Read site media purpose policy first; optimize images locally. Obtain attachment bytes yourself, then PUT each variant with its requiredHeaders and call commit_media_upload. Never store upload URLs as published images or ask the user to run terminal commands.", input({ filename: { type: "string", maxLength: 255 }, purpose: string, variants: { type: "array", minItems: 1, items: input({ mimeType: string, byteSize: { type: "integer", minimum: 1 }, role: { type: "string", enum: ["primary", "alternate", "fallback"] } }, ["mimeType", "byteSize", "role"]) }, ...texts }, ["filename", "purpose", "variants"]), "create"),
12
- define("commit_media_upload", "Verify all uploaded variants and commit one permanent media asset. Store the returned asset id in fields referencing media_assets.id, never a temporary upload URL. Unknown or expired upload groups require a new create_media_upload.", input({ uploadGroupId: string, ...texts }, ["uploadGroupId"]), "commit"),
13
- define("list_media_assets", "List committed image assets, newest first, with their permanent variants and alt/caption. Search matches id, alt or caption; reuse only a returned cursor.", input({ limit: { type: "integer", minimum: 1, maximum: 500 }, cursor: string, search: string }), "list", true),
14
- define("get_media_asset", "Read one committed media asset and its permanent variant URLs.", input({ id: string }, ["id"]), "get", true),
15
- define("update_media_asset", "Update committed asset alt or caption; omitted fields remain unchanged and empty strings clear text.", input({ id: string, ...texts }, ["id"]), "update"),
16
- define("delete_media_asset", "Delete an asset's objects and library record. Check application references first. A partial failure can be retried using the same id.", input({ id: string }, ["id"]), "delete"),
15
+ define("upload_media", "Upload one image to the media library in two calls; bytes never pass through MCP and file content is never accepted as base64. "
16
+ + "1. Call with purpose, filename and variants (one per slot the purpose takes, exactly one primary; optimize the image locally first). The result has an uploadId and the upload targets, which expire in 15 minutes and serve this one upload. PUT each variant's bytes to its target url with its headers. "
17
+ + "2. Call with { uploadId } to commit, and store the returned asset id in fields that reference media_assets.id, never an upload URL. An unknown or expired uploadId needs a new first call. "
18
+ + "Called with only a purpose, it returns that purpose's accepted types and byte caps; a host that shows MCP Apps opens an upload card where the person picks the file, and the card commits it and reports the asset id. "
19
+ + "With purpose and sourceUrl (a public https image) or file (an attachment), the server fetches the image and commits it in one call; that takes a purpose with a single variant slot.", input({ purpose, filename: { type: "string", maxLength: 255 }, variants, ...texts, uploadId: { type: "string", minLength: 1, maxLength: 100 },
20
+ sourceUrl: { type: "string", format: "uri", maxLength: 2048 },
21
+ file: { type: "object", properties: { download_url: { type: "string", maxLength: 4096 }, file_id: { type: "string", maxLength: 255 } }, required: ["download_url", "file_id"] } }), "upload", false, { "openai/fileParams": ["file"] }),
22
+ define("find_media", "Find committed image assets with their permanent variants and alt/caption: by ids (ids not found are listed in missing), or newest first, where search matches id, alt or caption; reuse only a returned cursor.", input({ ids: { type: "array", minItems: 1, maxItems: 100, items: { type: "string", minLength: 1 } }, ...paging }), "find", true),
23
+ define("update_media", "Update a committed asset's alt or caption; omitted fields remain unchanged and empty strings clear text.", input({ id: string, ...texts }, ["id"]), "update"),
24
+ define("delete_media", "Delete an asset's objects and library record. Check application references first. A partial failure can be retried using the same id.", input({ id: string }, ["id"]), "delete"),
25
+ define("get_media_upload_policy", old("call upload_media with only a purpose. Read declared image purpose MIME slots and byte caps before preparing variants or requesting an upload. Optimization runs locally, never in the host."), input({}), "policy", true),
26
+ define("create_media_upload", old("use upload_media. Create short-lived PUT capabilities for image variants. Read site media purpose policy first; optimize images locally. Obtain attachment bytes yourself, then PUT each variant with its requiredHeaders and call commit_media_upload. Never store upload URLs as published images or ask the user to run terminal commands."), input({ filename: { type: "string", maxLength: 255 }, purpose: string, variants, ...texts }, ["filename", "purpose", "variants"]), "create"),
27
+ define("commit_media_upload", old("call upload_media with { uploadId }. Verify all uploaded variants and commit one permanent media asset. Store the returned asset id in fields referencing media_assets.id, never a temporary upload URL. Unknown or expired upload groups require a new create_media_upload."), input({ uploadGroupId: string, ...texts }, ["uploadGroupId"]), "commit"),
28
+ define("list_media_assets", old("use find_media. List committed image assets, newest first, with their permanent variants and alt/caption. Search matches id, alt or caption; reuse only a returned cursor."), input(paging), "list", true),
29
+ define("get_media_asset", old("use find_media with ids. Read one committed media asset and its permanent variant URLs."), input({ id: string }, ["id"]), "get", true),
30
+ define("update_media_asset", old("use update_media. Update committed asset alt or caption; omitted fields remain unchanged and empty strings clear text."), input({ id: string, ...texts }, ["id"]), "update"),
31
+ define("delete_media_asset", old("use delete_media. Delete an asset's objects and library record. Check application references first. A partial failure can be retried using the same id."), input({ id: string }, ["id"]), "delete"),
17
32
  ];
18
33
  }
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
1
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { createRequire } from "node:module";
3
3
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
4
  import { pathToFileURL } from "node:url";
@@ -85,6 +85,8 @@ export async function bundleApp(entry, outdir, assets = join(outdir, "client"),
85
85
  refuse(error);
86
86
  return;
87
87
  }
88
+ apart();
89
+ copyPublic(outdir, assets);
88
90
  if (dirs.some(dir => generatedTarget(join(dir, "generated.ts"))?.startsWith("bun-"))) {
89
91
  if (Object.values(own.inputs).some(input => input.imports.some(imported => /^@aotter\/mantle-admin\/server(?:\/|$)/.test(imported.path))))
90
92
  await bundleAdmin(source, outdir, join(assets, "admin"));
@@ -103,7 +105,34 @@ export async function bundleApp(entry, outdir, assets = join(outdir, "client"),
103
105
  }
104
106
  if (Object.keys(metafile.inputs).some(input => /(?:^|\/)mantle-admin\/(?:dist|src)\/server\/index\.[jt]s$/.test(input.replaceAll("\\", "/")))) {
105
107
  await bundleAdmin(source, outdir, join(assets, "admin"));
108
+ adminHeaders(assets);
109
+ }
110
+ }
111
+ function copyPublic(outdir, assets) {
112
+ const record = join(outdir, ".mantle-public"), source = resolve("public"), target = resolve(assets);
113
+ if (existsSync(record)) {
114
+ const previous = JSON.parse(readFileSync(record, "utf8"));
115
+ if (previous.assets === target)
116
+ for (const path of previous.files)
117
+ rmSync(join(assets, path), { force: true });
106
118
  }
119
+ if (!existsSync(source) || within(source, target) || within(target, source))
120
+ return void rmSync(record, { force: true });
121
+ if (existsSync(join(source, "admin")))
122
+ throw new Error("public/admin is reserved for Admin's build; move those files elsewhere under public/.");
123
+ const files = readdirSync(source, { recursive: true, withFileTypes: true })
124
+ .filter(entry => entry.isFile() || entry.isSymbolicLink()).map(entry => relative(source, join(entry.parentPath, entry.name)));
125
+ cpSync(source, assets, { recursive: true });
126
+ mkdirSync(outdir, { recursive: true });
127
+ writeFileSync(record, JSON.stringify({ assets: target, files }));
128
+ }
129
+ const adminRule = "/admin/assets/*\n X-Content-Type-Options: nosniff\n Cache-Control: public, max-age=31536000, immutable\n";
130
+ function adminHeaders(assets) {
131
+ const file = join(assets, "_headers");
132
+ if (!existsSync(file))
133
+ return writeFileSync(file, adminRule);
134
+ if (!readFileSync(file, "utf8").includes("/admin/assets/*"))
135
+ console.warn(`note: ${relative(process.cwd(), file)} has no /admin/assets/* rule; add:\n${adminRule}`);
107
136
  }
108
137
  async function bundleAdmin(entry, outdir, out) {
109
138
  const source = join(dirname(entry), "admin.tsx"), custom = existsSync(source);
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { existsSync, readFileSync } from "node:fs";
2
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
3
3
  import { dirname, relative, resolve } from "node:path";
4
4
  export const INPUTS_FILE = ".mantle-inputs.json";
5
5
  const MANTLE_VERSION = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version;
@@ -20,7 +20,7 @@ export function staleInputs(generatedModule) {
20
20
  const stale = recorded.mantle === MANTLE_VERSION ? [] : [`generated by Mantle ${recorded.mantle}, building with ${MANTLE_VERSION}`];
21
21
  for (const [path, hash] of Object.entries(recorded.inputs))
22
22
  if (digest(resolve(directory, path)) !== hash)
23
- stale.push(`${relative(process.cwd(), resolve(directory, path))} changed`);
23
+ stale.push(`${shown(resolve(directory, path))} changed`);
24
24
  return stale;
25
25
  }
26
26
  export function extraRoles(source) {
@@ -30,3 +30,4 @@ export function authTableSql(source) {
30
30
  const sql = source.split("\n").filter(line => /\b(?:SELECT|INSERT|UPDATE|DELETE)\b/.test(line)).join("\n");
31
31
  return [...new Set([...sql.matchAll(/\b(?:FROM|JOIN|UPDATE|INTO)\s+"?(user|session|account|verification)"?(?=[\s;),`'"]|$)/gi)].map(m => m[1].toLowerCase()))];
32
32
  }
33
+ const shown = (path) => relative(process.cwd(), existsSync(path) ? realpathSync(path) : path);
@@ -3,6 +3,7 @@ import { createRequire } from "node:module";
3
3
  import { basename, dirname, join, relative, resolve } from "node:path";
4
4
  import { isDeepStrictEqual } from "node:util";
5
5
  import { applyEdits, modify, parse } from "jsonc-parser";
6
+ const workerFirst = ["/*", "!/admin/assets/*", "!/assets/*"];
6
7
  const requiredFlags = ["nodejs_compat", "global_fetch_strictly_public"];
7
8
  function compatibilityDate(dir) {
8
9
  try {
@@ -34,7 +35,7 @@ export function configureWrangler(configPath, entry, outdir, generated, check =
34
35
  throw new Error("--check requires a JSON or JSONC Wrangler config; TOML generation only prints manual edits.");
35
36
  console.log(`Leave ${configPath} unchanged. Add these Wrangler TOML keys (include nodejs_compat and global_fetch_strictly_public in every compatibility_flags override):\n` +
36
37
  `main = ${JSON.stringify(keys.main)}\nno_bundle = true\nfind_additional_modules = true\nbase_dir = ${JSON.stringify(worker)}\ncompatibility_flags = ${JSON.stringify(requiredFlags)}\n\n` +
37
- (assets === undefined ? "" : `[assets]\ndirectory = ${JSON.stringify(local(assets))}\nbinding = "ASSETS"\nrun_worker_first = true\nhtml_handling = "none"\n\n`) +
38
+ (assets === undefined ? "" : `[assets]\ndirectory = ${JSON.stringify(local(assets))}\nbinding = "ASSETS"\nrun_worker_first = ${JSON.stringify(workerFirst)}\nhtml_handling = "none"\n\n`) +
38
39
  `[[rules]]\ntype = "ESModule"\nglobs = ["**/*.js"]\nfallthrough = true\n\n[build]\ncommand = ${JSON.stringify(keys.build.command)}\nwatch_dir = ${JSON.stringify(keys.build.watch_dir)}`);
39
40
  return false;
40
41
  }
@@ -82,8 +83,8 @@ export function configureWrangler(configPath, entry, outdir, generated, check =
82
83
  if (ownsDefaults && current?.binding === undefined)
83
84
  edit([...path, "binding"], "ASSETS");
84
85
  if (ownsDefaults && current?.run_worker_first === undefined)
85
- edit([...path, "run_worker_first"], true);
86
- if (current?.html_handling === undefined && (current?.run_worker_first === true || (ownsDefaults && current?.run_worker_first === undefined)))
86
+ edit([...path, "run_worker_first"], workerFirst);
87
+ if (current?.html_handling === undefined && (current?.run_worker_first === true || Array.isArray(current?.run_worker_first) || (ownsDefaults && current?.run_worker_first === undefined)))
87
88
  edit([...path, "html_handling"], "none");
88
89
  };
89
90
  wireAssets(["assets"], config.assets);
@@ -1,4 +1,5 @@
1
1
  import { type Authorize, type Target, type Caller } from "./contracts.js";
2
2
  /** Rejections deny; synchronous application failures remain internal errors. */
3
3
  export declare function authorized(authorize: Authorize, target: Target, caller: Caller): Promise<boolean>;
4
+ /** A rejection with an `AUTH_DENIED` ContractError keeps its message (e.g. the role that may call the target). */
4
5
  export declare function authorizeTarget(authorize: Authorize, target: Target, caller: Caller): Promise<void>;
@@ -1,5 +1,16 @@
1
1
  import { ContractError } from "./contracts.js";
2
2
  export async function authorized(authorize, target, caller) {
3
+ return (await decide(authorize, target, caller)) === true;
4
+ }
5
+ export async function authorizeTarget(authorize, target, caller) {
6
+ const decision = await decide(authorize, target, caller);
7
+ if (decision === true)
8
+ return;
9
+ if (decision instanceof ContractError && decision.code === "AUTH_DENIED")
10
+ throw decision;
11
+ throw new ContractError("AUTH_DENIED", "Target is not authorized.", target.kind + "/" + target.name);
12
+ }
13
+ async function decide(authorize, target, caller) {
3
14
  let result;
4
15
  try {
5
16
  result = authorize(target, caller);
@@ -7,9 +18,5 @@ export async function authorized(authorize, target, caller) {
7
18
  catch (cause) {
8
19
  throw new Error("Authorization failed.", { cause });
9
20
  }
10
- return await Promise.resolve(result).catch(() => false) === true;
11
- }
12
- export async function authorizeTarget(authorize, target, caller) {
13
- if (!await authorized(authorize, target, caller))
14
- throw new ContractError("AUTH_DENIED", "Target is not authorized.", target.kind + "/" + target.name);
21
+ return Promise.resolve(result).catch((error) => error);
15
22
  }
@@ -1,5 +1,5 @@
1
1
  import type { Caller } from "./contracts.js";
2
- import type { Tool } from "./mcp.js";
3
- export declare function callTool<Database, Handlers>(tool: Tool<Database, Handlers>, raw: Record<string, unknown>, db: Database, caller: Caller, handlers: Handlers): Promise<unknown>;
2
+ import type { Tool, ToolCall } from "./mcp.js";
3
+ export declare function callTool<Database, Handlers>(tool: Tool<Database, Handlers>, raw: Record<string, unknown>, db: Database, caller: Caller, handlers: Handlers, call?: ToolCall): Promise<unknown>;
4
4
  /** Transport input is checked before acquiring an application-owned native handle. */
5
- export declare function callToolLazy<Database, Handlers>(tool: Tool<Database, Handlers>, raw: Record<string, unknown>, getDb: () => Database, caller: Caller, handlers: Handlers, dryRun?: () => unknown): Promise<unknown>;
5
+ export declare function callToolLazy<Database, Handlers>(tool: Tool<Database, Handlers>, raw: Record<string, unknown>, getDb: () => Database, caller: Caller, handlers: Handlers, dryRun?: () => unknown, call?: ToolCall): Promise<unknown>;
@@ -1,9 +1,9 @@
1
1
  import { convert, invalid } from "./surface.js";
2
2
  import { checkPageLimit } from "./paging.js";
3
- export async function callTool(tool, raw, db, caller, handlers) {
4
- return callToolLazy(tool, raw, () => db, caller, handlers);
3
+ export async function callTool(tool, raw, db, caller, handlers, call) {
4
+ return callToolLazy(tool, raw, () => db, caller, handlers, undefined, call);
5
5
  }
6
- export async function callToolLazy(tool, raw, getDb, caller, handlers, dryRun) {
6
+ export async function callToolLazy(tool, raw, getDb, caller, handlers, dryRun, call = { apps: false }) {
7
7
  if (tool.page) {
8
8
  const { limit, cursor, ...input } = raw;
9
9
  if (limit !== undefined)
@@ -20,5 +20,5 @@ export async function callToolLazy(tool, raw, getDb, caller, handlers, dryRun) {
20
20
  convert(input, tool.convert);
21
21
  if (dryRun)
22
22
  return dryRun();
23
- return tool.run(getDb(), caller, input, handlers);
23
+ return tool.run(getDb(), caller, input, handlers, call);
24
24
  }
@@ -184,7 +184,7 @@ export function handler(tools, options, apps = {}, reads = []) {
184
184
  tool.check?.(caller);
185
185
  await authorizeTarget(options.authorize, tool.target ?? { kind: tool.page ? "view" : "procedure", name: tool.name }, caller);
186
186
  }
187
- if ((Array.isArray(parsedBody) ? parsedBody : [parsedBody]).some(message => message?.method === "tools/list" || message?.method === "resources/read")) {
187
+ if (options.discovery !== "all" && (Array.isArray(parsedBody) ? parsedBody : [parsedBody]).some(message => message?.method === "tools/list" || message?.method === "resources/read")) {
188
188
  const visible = [];
189
189
  for (const tool of selected) {
190
190
  if (caller.kind === "anonymous" && tool.closed || !options.gateway && caller.kind === "user" && caller.credential === "oauth" && tool.scopes?.some(scope => !caller.scopes.includes(scope)))
@@ -214,7 +214,7 @@ export function handler(tools, options, apps = {}, reads = []) {
214
214
  server.server.registerCapabilities({ tools: { listChanged: false } });
215
215
  if (host)
216
216
  server.server.onclose = () => access?.finish(isRecord(parsedBody) && parsedBody.method === "initialize" ? undefined : new ContractError("INPUT_VALIDATION_FAILED", "MCP request refused.", "mcp", { status: 400 }));
217
- const enabled = clientUiSupport(parsedBody) !== "unsupported";
217
+ const support = clientUiSupport(parsedBody), enabled = support !== "unsupported", apps = support === "supported";
218
218
  const appMeta = links ? registerApps(server, links, enabled, new Set(selected.map(tool => tool.name)), { env, request }) : undefined;
219
219
  const registered = new Set(), set = server.server.setRequestHandler.bind(server.server);
220
220
  const size = options.toolsPageSize;
@@ -234,6 +234,7 @@ export function handler(tools, options, apps = {}, reads = []) {
234
234
  continue;
235
235
  const meta = linked && own ? { ...linked, ...own, ui: { ...linked.ui, ...own.ui } } : linked ?? own;
236
236
  registered.add(tool.name);
237
+ const call = { apps: apps && !!linked };
237
238
  const about = (contract) => { const text = [tool.version && `tool version ${tool.version}`, contract && `input schema ${JSON.stringify(tool.page ? paging(tool.inputSchema) : tool.inputSchema)}`].filter(Boolean).join("; "); return text ? [{ type: "text", text }] : []; };
238
239
  server.registerTool(tool.name, { ...(meta ? { _meta: meta } : {}), ...(tool.title ? { title: text(tool.title, locale) } : {}), description: text(tool.description, locale) ?? text(tool.title, locale) ?? tool.name, inputSchema: schema(tool.page ? paging(tool.inputSchema) : tool.inputSchema), ...(tool.outputSchema ? { outputSchema: schema(tool.outputSchema) } : {}), ...(tool.annotations ? { annotations: tool.annotations } : {}) }, async (raw) => {
239
240
  const metadata = options.onObservation ? { kind: "invocation", surface, invocationId: crypto.randomUUID(), tool: tool.name, caller: Object.freeze(caller.kind === "user" ? { ...caller, scopes: Object.freeze([...caller.scopes]) } : { ...caller }) } : undefined;
@@ -248,7 +249,7 @@ export function handler(tools, options, apps = {}, reads = []) {
248
249
  checkOrigin(request, caller);
249
250
  let db, resultMeta;
250
251
  const run = async (input) => {
251
- const out = await callTool(tool, input, () => db ??= options.db(env), caller, options.handlers, access?.dryRun ? () => ({ dryRun: true, event: access.finish() }) : undefined);
252
+ const out = await callTool(tool, input, () => db ??= options.db(env), caller, options.handlers, access?.dryRun ? () => ({ dryRun: true, event: access.finish() }) : undefined, call);
252
253
  return isRecord(out) && Symbol.for("net.aotter.mantle/result") in out ? (resultMeta = out._meta, out.data) : out;
253
254
  };
254
255
  let data = await run(raw);
@@ -10,6 +10,12 @@ export type { McpApps, McpAppsSource, McpAppResource, McpAppRender, McpAppPage,
10
10
  export interface Options<Env, Database> extends GatewayOptions {
11
11
  apps?: Readonly<Partial<Record<"public" | "admin", McpAppsSource | readonly McpAppsSource[]>>>;
12
12
  authorize?: Authorize;
13
+ /**
14
+ * With `authorize`: `"authorized"` (default) lists only the tools each caller may call, and resource HTML loaders get
15
+ * those names; `"all"` lists the endpoint's whole catalogue to every caller, so a host's saved tool list stays valid
16
+ * when a caller's grant changes, and `authorize` decides at call time. Listing a tool grants nothing either way.
17
+ */
18
+ discovery?: "authorized" | "all";
13
19
  gateway?: Gateway;
14
20
  fields?: import("./user-fields.js").UserFields;
15
21
  db: (env: Env) => Database;
@@ -99,7 +105,7 @@ export interface Tool<Database, Handlers = never> {
99
105
  /** A View tool: its operation path and paged boundary. */
100
106
  readonly path?: string;
101
107
  readonly page?: (db: Database, caller: Caller, input: never, paging: Paging) => Promise<unknown>;
102
- readonly run?: (db: Database, caller: Caller, input: never, handlers: Handlers) => Promise<unknown>;
108
+ readonly run?: (db: Database, caller: Caller, input: never, handlers: Handlers, call: ToolCall) => Promise<unknown>;
103
109
  /**
104
110
  * Merged into this tool's `_meta` in `tools/list` (top level shallow, `ui` one level deep) with what its App links produce;
105
111
  * a contradicting value fails at `mcp(...)`. `ui.visibility: ["app"]` hides the tool from clients without MCP Apps.
@@ -107,16 +113,12 @@ export interface Tool<Database, Handlers = never> {
107
113
  */
108
114
  readonly meta?: Readonly<Record<string, unknown>>;
109
115
  }
110
- /** What `withResultMeta` returns: `data` is the tool result as usual, `_meta` goes only to the `tools/call` result `_meta`. */
111
- export interface ResultWithMeta<T> {
112
- readonly data: T;
113
- readonly _meta: Readonly<Record<string, unknown>>;
116
+ export { withResultMeta, type ResultWithMeta } from "./result-meta.js";
117
+ /** What a `tools/call` tells a tool beside its input. */
118
+ export interface ToolCall {
119
+ /** The request declared MCP Apps support, so an App renders the result and reads its `_meta`; a secret for the App belongs there, not in the data a model reads. */
120
+ readonly apps: boolean;
114
121
  }
115
- /**
116
- * Returned from a tool's `run`: MCP sends `data` exactly as an unwrapped return, and `meta` only in the result `_meta`
117
- * (beside `net.aotter.mantle/tool`, which it cannot replace). `callTool` returns the wrapper itself; drop `_meta` before forwarding to a model.
118
- */
119
- export declare const withResultMeta: <T>(data: T, meta: Readonly<Record<string, unknown>>) => ResultWithMeta<T>;
120
122
  /** Request-bound tool tables keep native environment bindings outside the cached App catalogue. */
121
123
  export type Tools<Env, Database, Handlers = never> = readonly Tool<Database, Handlers>[] | ((request: Request, env: Env) => readonly Tool<Database, Handlers>[] | Promise<readonly Tool<Database, Handlers>[]>);
122
124
  /** The selected MCP routes; all request work loads only when an MCP request arrives. */
@@ -1,7 +1,7 @@
1
1
  import { linkApps, linkSurface, resourcesOf } from "./mcp-apps.js";
2
2
  export const callTool = async (...args) => (await import("./mcp-call.js")).callTool(...args);
3
3
  export const callToolLazy = async (...args) => (await import("./mcp-call.js")).callToolLazy(...args);
4
- export const withResultMeta = (data, meta) => ({ [Symbol.for("net.aotter.mantle/result")]: true, data, _meta: meta });
4
+ export { withResultMeta } from "./result-meta.js";
5
5
  export function mcp(tools, options, reads = []) {
6
6
  let serve;
7
7
  const linked = {};
@@ -0,0 +1,109 @@
1
+ import { ContractError } from "./contracts.js";
2
+ import { mediaError } from "./media.js";
3
+ const REDIRECTS = 3;
4
+ const TIMEOUT_MS = 20_000;
5
+ const fail = (code, message) => mediaError(code, message, "media/source");
6
+ const BLOCKED_V4 = [
7
+ [0x00000000, 8], [0x0a000000, 8], [0x64400000, 10], [0x7f000000, 8], [0xa9fe0000, 16], [0xac100000, 12], [0xc0000000, 24], [0xc0000200, 24],
8
+ [0xc0586300, 24], [0xc0a80000, 16], [0xc6120000, 15], [0xc6336400, 24], [0xcb007100, 24], [0xe0000000, 4], [0xf0000000, 4],
9
+ ];
10
+ const LOCAL_NAME = /(^|\.)(localhost|local|localdomain|internal|intranet|lan|home\.arpa)$/;
11
+ export function publicUrl(value, base) {
12
+ let url;
13
+ try {
14
+ url = new URL(value, base);
15
+ }
16
+ catch {
17
+ throw fail("MEDIA_SOURCE_REJECTED", "sourceUrl is not a URL");
18
+ }
19
+ if (url.protocol !== "https:")
20
+ throw fail("MEDIA_SOURCE_REJECTED", "only https URLs are fetched");
21
+ if (url.username || url.password)
22
+ throw fail("MEDIA_SOURCE_REJECTED", "a URL with credentials is refused");
23
+ if (url.port)
24
+ throw fail("MEDIA_SOURCE_REJECTED", "only the default https port is fetched");
25
+ const host = url.hostname.replace(/\.$/, "").toLowerCase();
26
+ if (host.startsWith("["))
27
+ throw fail("MEDIA_SOURCE_REJECTED", "IP address literals other than public IPv4 are refused");
28
+ const quad = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(host);
29
+ if (quad) {
30
+ const ip = quad.slice(1).reduce((n, part) => n * 256 + Number(part), 0);
31
+ if (BLOCKED_V4.some(([start, bits]) => Math.floor(ip / 2 ** (32 - bits)) === Math.floor(start / 2 ** (32 - bits))))
32
+ throw fail("MEDIA_SOURCE_REJECTED", "private, loopback, link-local and reserved addresses are refused");
33
+ }
34
+ else if (!host.includes(".") || LOCAL_NAME.test(host))
35
+ throw fail("MEDIA_SOURCE_REJECTED", `${host} is not a public host`);
36
+ return url;
37
+ }
38
+ function sniff(b) {
39
+ const ascii = (from, to) => String.fromCharCode(...b.subarray(from, to));
40
+ if (b[0] === 0x89 && ascii(1, 4) === "PNG")
41
+ return "image/png";
42
+ if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff)
43
+ return "image/jpeg";
44
+ if (ascii(0, 4) === "GIF8")
45
+ return "image/gif";
46
+ if (ascii(0, 4) === "RIFF" && ascii(8, 12) === "WEBP")
47
+ return "image/webp";
48
+ if (ascii(4, 8) === "ftyp" && ["avif", "avis"].includes(ascii(8, 12)))
49
+ return "image/avif";
50
+ return undefined;
51
+ }
52
+ export async function fetchImage(source, maxBytes) {
53
+ let url = publicUrl(source), response;
54
+ const signal = AbortSignal.timeout(TIMEOUT_MS);
55
+ for (let hops = 0;; hops++) {
56
+ try {
57
+ response = await fetch(url, { redirect: "manual", signal, headers: { accept: "image/*" } });
58
+ }
59
+ catch {
60
+ throw fail("MEDIA_SOURCE_FAILED", `${url.host} could not be fetched`);
61
+ }
62
+ const location = response.status >= 300 && response.status < 400 ? response.headers.get("location") : null;
63
+ if (!location)
64
+ break;
65
+ await response.body?.cancel();
66
+ if (hops === REDIRECTS)
67
+ throw fail("MEDIA_SOURCE_REJECTED", `more than ${REDIRECTS} redirects`);
68
+ url = publicUrl(location, url.href);
69
+ }
70
+ if (!response.ok || !response.body) {
71
+ await response.body?.cancel();
72
+ throw fail("MEDIA_SOURCE_FAILED", `${url.host} answered ${response.status}`);
73
+ }
74
+ const declared = Number(response.headers.get("content-length"));
75
+ const tooLarge = () => fail("MEDIA_VARIANT_SIZE_EXCEEDED", `the source is larger than the ${maxBytes} bytes this purpose allows`);
76
+ if (declared > maxBytes) {
77
+ await response.body.cancel();
78
+ throw tooLarge();
79
+ }
80
+ const reader = response.body.getReader(), chunks = [];
81
+ let size = 0;
82
+ try {
83
+ for (let next = await reader.read(); !next.done; next = await reader.read()) {
84
+ size += next.value.byteLength;
85
+ if (size > maxBytes) {
86
+ await reader.cancel();
87
+ throw tooLarge();
88
+ }
89
+ chunks.push(next.value);
90
+ }
91
+ }
92
+ catch (error) {
93
+ if (error instanceof ContractError)
94
+ throw error;
95
+ throw fail("MEDIA_SOURCE_FAILED", `${url.host} stopped sending`);
96
+ }
97
+ const bytes = new Uint8Array(size);
98
+ chunks.reduce((at, chunk) => (bytes.set(chunk, at), at + chunk.byteLength), 0);
99
+ const mimeType = sniff(bytes);
100
+ if (!mimeType)
101
+ throw fail("MEDIA_MIME_REJECTED", "the source is not a PNG, JPEG, GIF, WebP or AVIF image");
102
+ let name = url.pathname.split("/").pop() ?? "";
103
+ try {
104
+ name = decodeURIComponent(name);
105
+ }
106
+ catch { }
107
+ name = name.slice(0, 255);
108
+ return { bytes, mimeType, filename: name || "upload" };
109
+ }