Skip to content

fix: seven agent-authoring rough edges in MCP tools, dashboards and skills - #1272

Open
jswir wants to merge 11 commits into
malloydata:mainfrom
jswir:fix/publisher-friction
Open

jswir wants to merge 11 commits into
malloydata:mainfrom
jswir:fix/publisher-friction

Conversation

@jswir

@jswir jswir commented Oct 1, 2026 •

Copy link
Copy Markdown
Collaborator

These came from one session of an agent building a model, a dashboard and a data app against a local Publisher (0.8.4) over MCP. Each fix is its own commit, so the PR can be read commit by commit.

The fixes

1. Skills stop teaching a percentile function (fix(skills): ...)
Malloy has none. #1249 said so in malloy-queries, but malloy-discover's tier-boundaries example still used sale_price.percentile(25), and the LookML and Power BI mappings pointed at a percentile function or a fn!() measure. The example now computes exact percentiles in plain Malloy with sum_cumulative, and the other sites point to it. Checked on DuckDB in restricted mode: 1..100 gives 25/50/75/95. NULLs must be filtered first or every percentile comes out high, and the skill says so.

2. execute_query's _meta lists each model annotation once (fix(mcp): ...)
Malloy returns a model's annotations once per imported file, so a model with six imports sent ##! experimental.access_modifiers six times in every response. The envelope keeps the last copy of each line. Tags fold last-wins with the importing model last, so the last copy keeps what they resolve to; keeping the first could let an import override the model.

3. A dashboard suggest can name a joined dimension (fix(dashboards): ...)
suggest { source=sales dimension="products.department" } loaded with "that source has no field". The SDK already runs that query correctly; only the load check was too strict. It now walks the source's joins. A bad path names the failing part: "sales" has no join "prodcts". The suggest runs as an ordinary query on sales, so it is gated exactly like the dashboard's own tiles. malloy-dashboards documents the quoted form.

4. MCP execute_query can take includeHiddenFilesAndSources, on an authoring server only (feat(mcp): ... + fix(mcp): offer ... only on an authoring server)
#1261/#1264 added it to REST only, so an author curating with index.malloy couldn't run a hidden file over MCP to test it. MCP now offers the same parameter, but only when publisher.config.json sets "mcp": { "includeHiddenFilesAndSources": true }. Off is the default. Off removes it from the tool's schema, and a value sent anyway is ignored. That keeps an agent answering questions on the curated surface. Credible's execute_query has no such parameter, and the eval harness limits its answerer by tool name, so it can't block a parameter. A non-boolean value is warned about and treated as off. It never lifts #(authorize) or #(access_filter), which a test pins. REST's parameter is unchanged. The off-surface error doesn't mention the flag.

5. An unknown environment names the ones that exist (fix(mcp): ...)
Every MCP tool answered environmentName: "analytics" with "Resource not found: analytics/bq_demo" and generic suggestions. It now says Environment 'analytics' not found. Available environments: default. Use a name from list_packages. One shared path in classifyToolError, which getModelForQuery now uses too. The REST 404 text is unchanged, because a router may scope a REST caller to one environment.

6. An ad-hoc query with more than one run: is a 400 (fix(server): ...)
Malloy runs only the last run:. An agent sent seven and silently got one answer back. MCP and REST now refuse it with the count. Definitions before a single run: still work. The count comes from the compiled query and is checked after every boundary and authorize gate, so a request a gate denies keeps the same 404 or 403 and the 400 can't reveal that a hidden source exists.

7. Package-scope compile_model reports the dashboard and render-tag findings a reload would (refactor(server): ... + fix(compile): ...)
compile_model said success, then reload_package reported a broken given annotation, a bad suggest field and an unknown render tag. Those checks only ran on reload. The first commit extracts reload's model hydration and render-tag check with no behavior change; the second runs it, plus a dry-run dashboard lint, from package-scope compile. A test checks the findings against a real reload of the same files. Storage, persist and materialization warnings still appear only on reload, and the tool descriptions say so. A follow-up commit (fix(compile): package scope reports an unparsed dashboard tag once) stops compile reporting a dashboard with an unparsable ## artifact tag twice; reload reports it once.

Decisions for review

  • Update README and malloy version. #6 is a behavior change. query_boundary.spec.ts had asserted that an all-curated multi-statement query "is legitimate and admitted"; it now expects the 400. Decoy cases still get their 404/403. If Malloy ever drops PreparedQuery.model.queries(), the count is undefined and the check stops refusing; single_run_statement.spec.ts would fail, but it doesn't fail closed at runtime.
  • Add accessToken to SDK. #7 changes status. The given and suggest findings are severity error, so a package that compiled "success" and reloaded with warnings now compiles "error". That matches compile's documented rule and the fact that the dashboard control is lost.
  • Add accessToken to SDK. #7 also changes one thing in reload. The dashboard text used by the query boundary now comes from the bytes that were compiled instead of a re-read from disk. They differ only if a file changes mid-compile.
  • Integration improvements #4 is off unless the config turns it on. An authoring launcher such as cred dev has to write "mcp": { "includeHiddenFilesAndSources": true } into its config; until it does, modeling agents there can't run hidden sources over MCP. The eval's serve.py writes a config without it, so eval answerers stay curated with no change. It is a config key, not an env var, because both launchers already write this file.

How it was checked

On this branch, from packages/server:

  • bun test ./src: 5036 pass, 36 fail. The 35 config/theme failures fail identically on unmodified origin/main under bun 1.4. The 36th was a cross-commit test expectation, fixed in the last commit, and the touched specs now pass 168/168.
  • Integration dashboards, render_tags, mcp, index_convention: 109 pass, 0 fail.
  • bun run lint and tsc --noEmit: clean.
  • bun run test:skills: 330 pass. bun run lint:skills: clean. The skills bundle regenerates with no diff.

Each fix was also tested on its own branch before being combined.

For the #4 gate, on the built server (dist/server.mjs) over the storefront example on DuckDB, with an index.malloy exporting only order_items:

  • Config with the mcp key: execute_query's schema lists the parameter, and a hidden source and a hidden file both run with it.
  • Config without it: the schema doesn't list it, and the same calls with true are refused with the normal off-surface error, which doesn't mention the flag.
  • REST with ?includeHiddenFilesAndSources=true still runs the hidden source on the server without the key.
  • execute_query_tool.spec.ts passes 22/22, and the MCP and config specs pass 426/426. Forcing the setting on or off in code fails the matching tests.

Left out

  • compile_model still silently uses the last run: with includeSql.
  • An unknown package in a known environment still gets the generic not-found.
  • get_context and list_packages don't take includeHiddenFilesAndSources, so an author can run a hidden source over MCP but can't search its fields.
  • The REST, notebook and SDK result payloads still carry the repeated annotations; only the MCP envelope dedupes. The real fix is in Malloy's getModelAnnotations.
  • malloy-charts and malloy-queries (analysis group) still say "query the percentiles" without the method, because the group rules stop them referencing malloy-discover.
  • An open dashboard page doesn't pick up a package reload, and its suggest options are cached for five minutes.

Supersedes #1271, which held fix 1 alone.

jswir added 8 commits October 1, 2026 00:23
malloy-discover's tier-boundaries example used sale_price.percentile(25),
and the LookML and Power BI mappings pointed at a percentile function or a
raw-SQL fn!() measure. None of those compile.

The example now computes exact percentiles in two stages with
sum_cumulative, in plain Malloy. malloy-define, malloy-gotchas-modeling and
the two mapping tables point to it.
Checked against in-memory DuckDB in restricted mode: 1..100 gives
p25/p50/p75/p95 of 25/50/75/95, and NULLs must be filtered first.

Signed-off-by: James Swirhun <james@credibledata.com>
Malloy returns a model's annotations once per file in its import graph, so
a model with six imports that each open with ##! experimental.access_modifiers
sent that line six times in every execute_query response.

The envelope now keeps the last copy of each repeated line, for model and
source annotations. Tags fold last-wins with the importing model last, so
keeping the last copy leaves what they resolve to unchanged.

Signed-off-by: James Swirhun <james@credibledata.com>
The suggest query already runs `group_by: products.department`, but the
package lint only looked at the source's own fields, so every dotted
dimension was reported as missing. The lint now walks the path through the
source's joins and, when it fails, names the join or field it could not find.

Signed-off-by: James Swirhun <james@credibledata.com>
…ides, like REST

The query route has includeHiddenFilesAndSources, but the MCP tool did not,
so an author working over MCP could not test a file index.malloy hides.
execute_query now takes the same optional boolean, default false, and passes
it to getQueryResults as the REST route does. The #(authorize) and
#(access_filter) gates still apply, and MCP still sends no authorize bypass.

Signed-off-by: James Swirhun <james@credibledata.com>
MCP tools answered an unknown environment with "Resource not found: env/conn"
and generic suggestions, and dropped the store's own error.

getEnvironment now attaches the requested name and the loaded environment
names to EnvironmentNotFoundError. classifyToolError, which getModelForQuery
now uses too, renders them, so every MCP tool says
"Environment 'x' not found. Available environments: ...". The REST 404
message is unchanged.

Signed-off-by: James Swirhun <james@credibledata.com>
…f running only the last

Malloy runs only the last run: statement of a text, so an ad-hoc query
with several returned one answer and silently dropped the rest. Ad-hoc
text with more than one run: is now a 400 that names the count, on both
the MCP execute_query tool and the REST query route. The count is taken
from the compiled query and checked after every boundary and authorize
gate, so a request a gate denies keeps the same 404 or 403 as before.

Signed-off-by: James Swirhun <james@credibledata.com>
Load and in-place reload each turned the worker's compiled models into
live Models and ran validateRenderTags in their own copy of the loop.
Both now call Package.hydrateWorkerModels, which differs only in what it
does with a model that failed: the load throws, the reload keeps a
placeholder. The load's package metadata is built by a named helper.
No behavior change.

Signed-off-by: James Swirhun <james@credibledata.com>
…ings a reload would

A package-scope compile_model ran only the worker compile, so a dashboard
that reload_package then flagged (a given annotation that does not parse, a
suggest naming a missing field, an unknown render tag) compiled clean.
It now runs reload's own hydration and dashboard discovery on a throwaway
Package and returns those findings at their own severity, without touching
the served package. Build-plan and manifest warnings stay reload-only.

Signed-off-by: James Swirhun <james@credibledata.com>
jswir added 3 commits October 1, 2026 13:44
…s reload does

Package-scope compile ran the notebook lint over dashboard files and then
added the dashboard lint's findings. A dashboard whose `## artifact` tag
does not parse was reported by both, while reload reports it once, from
the dashboard lint. Both paths now drop the notebook lint's copy through
one rule, reportedByDashboardLint.

The compile-equals-reload spec now compares the two finding lists in
both directions and covers the unparsed tag, so a finding only one side
reports, or one side reports twice, fails it. The notebook_lint
integration spec expects the package-scope findings this branch added.
Two comments that said render-tag findings and dashboard discovery only
happen at load are corrected.

Signed-off-by: James Swirhun <james@credibledata.com>
execute_query's includeHiddenFilesAndSources lets a caller run what a
package's index.malloy hides. A modeling agent needs that. An agent
answering questions must stay on the curated surface, and Credible's
execute_query has no such parameter. But the eval harness limits its
answerer by tool name, and this is a parameter on a tool the answerer
is allowed, so nothing stopped an answerer from using it. The
off-surface error even told every agent to pass it.

The parameter is now offered only when publisher.config.json sets

  "mcp": { "includeHiddenFilesAndSources": true }

The default is off. Off removes it from the tool's schema, and a value
sent anyway is ignored. A non-boolean value is warned about and treated
as off, as a bad theme field is. The off-surface hint that suggested
the flag is gone. REST's parameter of the same name is unchanged.

The setting is a config key rather than an env var because both
launchers that care already write this file: cred dev turns it on, and
the eval's serve.py writes a config without it, so it stays off.

Signed-off-by: James Swirhun <james@credibledata.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant