Skip to content

feat(mcp): expose schema as a Resource at arcadedb://{database}/schema (#4865)#5280

Merged
robfrank merged 8 commits into
mainfrom
feat/4865-mcp-schema-resource
Jul 15, 2026
Merged

feat(mcp): expose schema as a Resource at arcadedb://{database}/schema (#4865)#5280
robfrank merged 8 commits into
mainfrom
feat/4865-mcp-schema-resource

Conversation

@robfrank

Copy link
Copy Markdown
Collaborator

Closes #4865. Part of epic #4859 (Wave 1).

What

Exposes each accessible database's schema as an MCP Resource at arcadedb://{database}/schema, over both the HTTP and stdio transports. A client that supports Resources can now auto-load schema at session start instead of spending a model turn on the get_schema tool. get_schema stays, and stays recommended, for clients that do not implement Resources.

initialize now advertises capabilities.resources with listChanged: false and subscribe: false. Both flags are honest: no change-notification or subscription machinery is built.

The refactor

MCPHttpHandler and MCPStdioServer were near-duplicates: each carried its own TOOLS_LIST, its own MCP_PROTOCOL_VERSION, its own method switch, and its own copies of the JSON-RPC envelope helpers. Adding a second protocol primitive would have paid that duplication twice more.

A new MCPDispatcher now owns the whole JSON-RPC surface (protocol version, tool list, instructions, gating, method routing, envelope shaping) and both transports collapse to thin adapters. Transports own I/O and their own parse errors; the dispatcher owns protocol.

Net effect: a whole new protocol primitive lands for net +112 lines of main source, because ~346 lines of transport duplication were deleted.

The schema walk was extracted to a single GetSchemaTool.buildSchema(Database, String). The tool and the resource both render from it, so the issue's headline criterion (tool output and resource content are identical) is true by construction rather than by coincidence. It cannot drift, because there is only one implementation.

Behavior change (stdio transport)

MCPStdioServer now honors MCPConfiguration.isEnabled() and isUserAllowed(), which it previously ignored, and its initialize response now includes the instructions block that the HTTP transport already returned. The default path is unaffected: main() sets enabled=true and authenticates as root, and root is the default sole entry in allowedUsers. A stdio deployment that had rewritten allowedUsers to omit root will now be refused, which is arguably a latent bug being fixed.

This warrants a release note.

Design notes

  • The resource URI is parsed with plain string operations, deliberately not java.net.URI. URI.getHost() applies hostname syntax rules: it lowercases the authority and returns null for an authority containing an underscore. ArcadeDB database names are case-sensitive and may legally contain _, so arcadedb://My_DB/schema would silently resolve to nothing under java.net.URI.
  • resources/read deliberately departs from the house error convention. MCPToolUtils.resolveDatabase enumerates the available databases in its error text so the model can self-correct. That is right for tools, where the model supplies the name from memory and can guess wrong. Here, unknown database, unauthorized database, and malformed URI all collapse to one indistinguishable -32002, so resources/read cannot be used to probe which databases exist. Safe because read URIs come from resources/list, so a legitimate caller never guesses one.
  • list hides, read errors. With allowReads=false, resources/list returns an empty array (a discovery call most clients issue unprompted, so a reads-disabled server stays quiet) while resources/read fails loudly with -32600.

Testing

Tool count stays at 11: this adds a protocol primitive, not a tool.

  • New MCPResourcesTest (8 tests): transport-free calls into MCPResources; read content equals GetSchemaTool.execute output; malformed-URI shapes; the java.net.URI case/underscore trap; URI round-trip.
  • MCPServerPluginTest (+8): resources capability advertised; resources/list set equals the list_databases set; get_schema output equals resources/read content text; -32002 for unknown database and malformed URI; unauthorized databases omitted from the list; allowReads=false behavior.
  • MCPStdioServerTest (+3): resources/list and resources/read over stdio; initialize advertises resources and now carries instructions.

The existing MCP suites are the regression net for the dispatcher extraction, and they pass completely unedited. The test diff against main is additions only - zero deletions in any test file - so the old suites remain an untainted control.

  • 100 MCP tests green across all 5 MCP test classes
  • Full server module green: 601 tests, 0 failures

Sequencing

This rewrites MCPHttpHandler.java and MCPStdioServer.java, the two files that #4860 (vector_search), #4863 (sample_records), and #4864 (upsert_entity/upsert_relationship) also edit. #4865 should land before those three, with the siblings rebased onto it: each of them adds a tool, which after this refactor means one TOOLS_LIST line and one switch arm instead of two of each.

Out of scope

resources/templates/list, subscriptions, any resource other than schema, and the out-of-tree arcadedb-docs update (tracked separately, consistent with how #4862 handled it). No new configuration flag; default permissions unchanged.

🤖 Generated with Claude Code

@mergify

mergify Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Tick the box to add this pull request to the merge queue (same as @mergifyio queue).

  • Queue this pull request

@codacy-production

codacy-production Bot commented Jul 14, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics -53 complexity

Metric Results
Complexity -53

View in Codacy

🟢 Coverage 94.90% diff coverage · -6.25% coverage variation

Metric Results
Coverage variation -6.25% coverage variation
Diff coverage 94.90% diff coverage

View coverage diff in Codacy

Coverage variation details
Coverable lines Covered lines Coverage
Common ancestor commit (6108123) 139797 104095 74.46%
Head commit (f3ef2f1) 171976 (+32179) 117315 (+13220) 68.22% (-6.25%)

Coverage variation is the difference between the coverage for the head and common ancestor commits of the pull request branch: <coverage of head commit> - <coverage of common ancestor commit>

Diff coverage details
Coverable lines Covered lines Diff coverage
Pull request (#5280) 196 186 94.90%

Diff coverage is the percentage of lines that are covered by tests out of the coverable lines that the pull request added or modified: <covered lines added or modified>/<coverable lines added or modified> * 100%

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@claude

claude Bot commented Jul 14, 2026

Copy link
Copy Markdown

Code Review: MCP schema-as-Resource (#5280)

Reviewed the full diff. This is a clean, well-motivated change: the MCPDispatcher extraction genuinely removes duplication, and routing buildSchema() through a single source of truth makes the "tool output == resource content" guarantee structural rather than coincidental. The design notes on java.net.URI (case-folding + underscore rejection) and the list-hides / read-errors split are thoughtful and correct. Test coverage is strong and additive (old suites untouched as a control). Nice work.

A few observations, mostly minor:

Minor / discussion

  1. JSON-RPC id is dropped on early-exit errors. In MCPDispatcher.dispatch, the isEnabled() check returns error(null, -32600, "MCP server is disabled", 503) before final Object id = request.opt("id") is read, even though request is available and may carry an id. Same for the auth-failure branch. Per JSON-RPC 2.0 the error response should echo the request id when one is present. Consider hoisting the id extraction above the isEnabled branch so disabled/unauthorized responses echo it. Low impact (clients rarely correlate on a disabled server), but cheap to make spec-correct.

  2. Shared mutable static TOOLS_LIST returned by reference. tools/list does new JSONObject().put("tools", TOOLS_LIST), so the shared static JSONArray is aliased into each response. It's only mutated in the static initializer and nothing downstream mutates it, so this is safe today - but it's a latent footgun if a future handler ever post-processes the tools array. A short comment noting the array is effectively immutable after init would help the next editor. (Pre-existing pattern, not introduced here.)

  3. resourcesRead reuses SecurityException for two distinct cases. With allowReads=false, MCPResources.read throws SecurityException ("Read operations are not allowed") -> -32600, which matches the documented behavior. Just flagging that this is the same exception type the tool path uses for unauthorized-database denials; here the two never collide because the unauthorized-DB case is folded into MCPResourceNotFoundException first. Correct as written - worth keeping in mind if the read path grows.

Things I checked and confirmed are fine

  • parseSchemaURI length guard (<= scheme+suffix) correctly rejects empty DB names and arcadedb:///schema; the '/' check rejects arcadedb://a/b/schema. Round-trips exactly against schemaURI().
  • Read-path parity with the tool: unknown DB, unauthorized DB, and malformed URI all collapse to -32002 as designed, so resources/read can't be used to enumerate databases.
  • No sensitive-data logging regression: method/URI logged at INFO, tool args sanitized (\n/\r/\t) and truncated to 100 chars.
  • notifications/initialized -> MCPResponse(204, null) correctly yields no reply on both transports.
  • stdio behavior change (now honoring isEnabled/isUserAllowed + emitting instructions) is correctly called out as release-note-worthy; the default root/enabled=true path is unaffected.

Overall: LGTM. The id-echo nit (#1) is the only thing I'd suggest addressing before merge, and even that is optional.

Reviewed with Claude Code.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements the MCP Schema Resource feature by exposing database schemas as MCP resources at arcadedb://{database}/schema over both HTTP and stdio transports. It extracts a transport-neutral MCPDispatcher to unify JSON-RPC routing, refactors GetSchemaTool to share schema-building logic, and introduces MCPResources for resource discovery and retrieval. Feedback suggests adding a defensive null check when retrieving the database in MCPResources.read to prevent a potential NullPointerException if a database is concurrently dropped.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +78 to +79
final Database database = server.getDatabase(databaseName);
final JSONObject schema = GetSchemaTool.buildSchema(database, databaseName);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To prevent potential NullPointerExceptions if the database fails to load or is concurrently dropped between the existence check and the retrieval, add a defensive null check for the database instance. Throwing MCPResourceNotFoundException is more appropriate here as it maps to a -32002 (Resource not found) error rather than a -32603 (Internal error).

Suggested change
final Database database = server.getDatabase(databaseName);
final JSONObject schema = GetSchemaTool.buildSchema(database, databaseName);
final Database database = server.getDatabase(databaseName);
if (database == null)
throw new MCPResourceNotFoundException("Resource not found: " + uri);
final JSONObject schema = GetSchemaTool.buildSchema(database, databaseName);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Holding off on this one - the null check is unreachable in this codebase.

server.getDatabase(name) delegates to getDatabase(name, false, true). That method never returns null: the fast path returns only when db != null && db.isOpen(), and the slow path either reuses an active instance, calls factory.open(), or throws (DatabaseOperationException, or the open failure itself). There is no path that assigns null to db before return db. So a failed or concurrently-dropped load throws rather than returning null - an added null check can never fire.

The concurrent-drop race is genuinely possible (drop between existsDatabase and getDatabase in the same request), but it's already handled: factory.open() throws, and the dispatcher's generic catch (Exception) maps that to -32603, which is what the spec's error table prescribes for "anything else". -32002 is reserved by design for unknown/unauthorized/malformed URIs to prevent using resources/read as a database-existence probe; a transient server-side drop-mid-read is neither a caller error nor a probing vector, so -32603 is the correct signal here.

No code change.

@codecov

codecov Bot commented Jul 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.34694% with 15 lines in your changes missing coverage. Please review.
✅ Project coverage is 65.52%. Comparing base (6108123) to head (00fd4ef).

Files with missing lines Patch % Lines
...in/java/com/arcadedb/server/mcp/MCPDispatcher.java 90.72% 10 Missing and 4 partials ⚠️
...ain/java/com/arcadedb/server/mcp/MCPResources.java 97.05% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##               main    #5280      +/-   ##
============================================
- Coverage     65.53%   65.52%   -0.02%     
  Complexity      941      941              
============================================
  Files          1706     1709       +3     
  Lines        139797   139781      -16     
  Branches      29937    29943       +6     
============================================
- Hits          91620    91593      -27     
- Misses        35743    35747       +4     
- Partials      12434    12441       +7     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@robfrank

Copy link
Copy Markdown
Collaborator Author

Addressed the review in 576182b.

#1 (JSON-RPC id dropped on early-exit errors) - fixed. id extraction is now hoisted to the top of dispatch() with a null-request guard (request != null ? request.opt("id") : null), and the disabled / unauthenticated / empty-body / unauthorized branches all echo it. New additive test disabledServerErrorEchoesRequestId asserts a 503 error body carries the request id. The pre-existing disabledMCP and unauthorizedUserDenied tests still pass unedited (they assert only on HTTP status), so the test diff vs main stays additions-only.

#2 (shared static TOOLS_LIST aliased into responses) - left as-is, no comment added. As you noted it's a pre-existing pattern, safe today (mutated only in the static initializer, never post-processed), and carried over verbatim from both original handlers. Per the repo convention that comments state behavioral invariants rather than annotate latent footguns, I'd rather not add one here; if a future handler ever post-processes the array, that handler is where the copy belongs.

#3 (SecurityException reuse in resourcesRead) - no change, matching your "correct as written". The unauthorized-database case is folded into MCPResourceNotFoundException upstream, so the two SecurityException sources never collide on the read path.

CI red (unit/integration/ha-integration/Meterian) is pre-existing on main: the latest main push has both Java CI - test and Meterian Scanner workflow failing, and all 5 unit failures are engine-module tests outside this diff. All 100 MCP tests pass.

@claude

claude Bot commented Jul 15, 2026

Copy link
Copy Markdown

Code Review: MCP schema Resource (#4865)

Reviewed the Java changes (MCPDispatcher, MCPResources, MCPResourceNotFoundException, the two transport adapters, GetSchemaTool.buildSchema) and the three test files. This is a clean, well-reasoned PR. The refactor is the real win: collapsing MCPHttpHandler/MCPStdioServer onto a shared MCPDispatcher removes the near-duplicate JSON-RPC surface, and landing a new protocol primitive for a net +112 main-source lines is a good trade. The PR description is unusually thorough and the design notes (URI parsing rationale, the -32002 collapse to prevent database probing, list hides / read errors) are all sound.

Verified

  • Behavior preservation of the extraction. Diffed against origin/main: the dispatch check order (auth -> enabled -> empty-body -> user-allowed), the tools/call switch, formatArgs/formatResult, and the error/result envelope helpers all match the previous MCPHttpHandler semantics, including the 200 default HTTP status for -32700/-32601. The "additions-only" test diff is a genuine regression net.
  • Documented stdio behavior change is accurate. Confirmed the old MCPStdioServer dispatched without ever consulting isEnabled()/isUserAllowed(); the new path routes through MCPDispatcher and now honors both. Flagging this for a release note is the right call - a stdio deployment that rewrote allowedUsers to drop root will now be refused.
  • Schema identity by construction. Both the tool and the resource render GetSchemaTool.buildSchema(...).toString(), so MCPResourcesTest.readReturnsSchemaIdenticalToGetSchemaTool holds structurally, not coincidentally.
  • URI parser. parseSchemaURI correctly rejects empty names, embedded slashes, and the too-short case, and the string-ops-over-java.net.URI choice is justified and directly tested (My_DB case/underscore trap).

Minor observations (non-blocking)

  1. Missing/empty uri on resources/read maps to -32002 "Resource not found: ". params.getString("uri", "") defaults to empty, which parses to null and surfaces as not-found with an empty URI echoed in the message. JSON-RPC purists would expect -32602 (invalid params) for an absent required field, but the current behavior is defensible and consistent with the deliberate "don't leak which databases exist" design. Low priority; if you keep -32002, the empty trailing message string reads a little oddly in logs.
  2. stdio run() conflates parse and dispatch failures. In the per-line try, both new JSONObject(line) and dispatch(request) sit inside the same block whose catch reports -32700 "Parse error". An unexpected runtime exception escaping dispatch would be mislabeled as a parse error. This is pre-existing structure (not a regression), and MCPDispatcher.dispatch catches its own tool/resource exceptions so escape is unlikely - but this refactor would have been a natural moment to narrow the try to just the parse.
  3. Double INFO logging for resources/read (dispatch logs the method, then resourcesRead logs method+uri). Matches the existing tools/call pattern, so it is consistent, just slightly redundant.
  4. The docs/superpowers/{plans,specs} files are consistent with the existing convention in the repo (many prior HA plans live there), so no concern.

Test coverage

Strong. MCPResourcesTest covers list/read, the reads-disabled split (empty list vs SecurityException), the malformed-URI matrix, unknown-database -32002, and the URI round-trip; the MCPServerPluginTest/MCPStdioServerTest additions cover the capability advertisement, list-set equality, tool/resource text equality, and stdio instructions. One small gap: no explicit test asserts that a database the authenticated user cannot access is omitted from list yet still returns -32002 (indistinguishable from unknown) on read - the "cannot probe" invariant is the security-relevant one and is only partially exercised.

Overall: LGTM. No blocking issues; the notes above are polish. Thanks for the clean refactor and the honest capability flags (listChanged:false, subscribe:false).

🤖 Generated with Claude Code

@robfrank

Copy link
Copy Markdown
Collaborator Author

Addressed the second review.

Test coverage gap (the security-relevant one) - fixed in f3ef2f1. New MCPResourcesTest.readUnauthorizedDatabaseIsIndistinguishableFromUnknown authenticates a user whose only grant is a non-existent database, then asserts that reading arcadedb://graph/schema raises MCPResourceNotFoundException (the same -32002 an unknown database yields) and that list omits graph. That exercises both halves of the cannot-probe invariant at the unit level; the HTTP resourcesListOmitsUnauthorizedDatabases already covered the list half over the wire. Test diff vs main stays additions-only.

On the four non-blocking observations, none actioned, reasoning below:

  1. Empty/missing uri -> -32002. Kept. An absent uri is a malformed URI, and folding it into -32002 is the same anti-probing behavior applied to unknown/unauthorized/malformed; returning -32602 for this one shape would make an empty uri observably distinct, which is exactly the distinction the design suppresses. The empty trailing message in logs is cosmetic and I'd rather not special-case the message for it.

  2. stdio run() conflates parse and dispatch failures. Left as-is. run() was deliberately carried over verbatim (the plan kept main()/run() unchanged so the extraction stayed behavior-preserving), and MCPDispatcher.dispatch catches all its own tool/resource exceptions and returns an MCPResponse rather than throwing, so nothing escapes to be mislabeled today. Narrowing the try to just the parse is a reasonable hardening but it is pre-existing structure and out of scope for this change; happy to file a follow-up if you want it tracked.

  3. Double INFO log on resources/read. Left as-is for parity: tools/call logs the same way (dispatch logs the method, the handler logs method+args/uri), and matching that pattern is worth more than shaving one redundant line.

  4. docs/superpowers plan+spec files - agreed, consistent with the existing convention; no action.

@claude

claude Bot commented Jul 15, 2026

Copy link
Copy Markdown

Review: MCP schema Resource + dispatcher extraction

Reviewed the main-source changes (MCPDispatcher, MCPResources, MCPResourceNotFoundException, GetSchemaTool, and the two transport adapters) and the test additions. This is a clean, well-reasoned PR - the design notes in the description match what the code actually does. Nice work.

What holds up well

  • buildSchema as single source of truth. Extracting the schema walk into GetSchemaTool.buildSchema(Database, String) and rendering both the tool and the resource from it makes "tool output == resource content" true by construction. The test asserting resources/read text equals GetSchemaTool.execute(...).toString() locks it in.
  • The dispatcher extraction is the right shape. MCPDispatcher owning protocol (version, tool list, gating, envelope helpers) with the transports collapsed to thin I/O adapters removes the real duplication, and the existing suites passing unedited is a credible regression net.
  • parseSchemaURI string parsing is correct and justified. Verified the length math: URI_SCHEME (11) + SCHEMA_SUFFIX (/schema, 7) = 18, and the length() <= 18 guard correctly rejects empty-db forms (arcadedb://schema, arcadedb:///schema) while the indexOf('/') check rejects embedded slashes. The java.net.URI case/underscore trap is real and worth avoiding.
  • Authorization is consistent between the tool path (MCPToolUtils.resolveDatabase) and the resource path (existsDatabase + canAccessToDatabase), both keyed off the same in-memory databases map, so list/read stay consistent with list_databases and read won't force-load an unopened DB.
  • The indistinguishable -32002 for unknown/unauthorized/malformed on read (vs. the enumerating error tools use) is a sound anti-probing choice given read URIs come from list.
  • Behavior change on stdio (now honoring isEnabled()/isUserAllowed()) is correctly called out as release-note-worthy. Good catch flagging it.

Minor points (non-blocking)

  1. SecurityException on reads-disabled maps to -32600 (Invalid Request). Semantically it's a permission/state condition rather than a malformed request. It's documented and internally consistent, so this is just a naming observation - a dedicated code or -32002 would read more precisely, but not worth churn.

  2. resources capability is advertised unconditionally in initialize, even when allowReads=false. A conforming client then sees the capability, gets an empty resources/list, and a -32600 on any read. Documented as intentional ("list stays quiet"), and fine - just flagging that a strict client could log this as a surprising capability/behavior mismatch.

  3. Genuine JSON-RPC notifications with an unknown method (a request with no id whose method isn't notifications/initialized) fall through to default -> error(id, -32601, ...) and produce an error response with id: null. Per JSON-RPC 2.0 a server should not reply to notifications at all. Very low severity and effectively unreachable in practice, but if you want strictness the dispatcher could suppress replies for any method starting with notifications/.

  4. Docs footprint. The two docs/superpowers/ files add ~1,550 lines of internal plan/spec markdown. If that mirrors how MCP: add full_text_search tool #4862 landed, fine - just confirming it's intended to live in-tree rather than being a working artifact.

Test coverage

Strong and appropriately layered: transport-free MCPResourcesTest (malformed-URI shapes, the URI trap, round-trip, reads-disabled), HTTP-path MCPServerPluginTest (capability advertised, list==list_databases set, tool==resource content, unauthorized omission), and stdio MCPStdioServerTest (list/read/initialize-carries-instructions). The additions-only test diff preserving the old suites as an untainted control is a nice touch.

I did not run the full server build locally; relying on the stated 100 MCP / 601 server green run and static review.

LGTM once the release note for the stdio gating change lands.

@robfrank robfrank merged commit 3315bf9 into main Jul 15, 2026
25 of 29 checks passed
@robfrank robfrank deleted the feat/4865-mcp-schema-resource branch July 15, 2026 17:00
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.

MCP: expose schema as a Resource

1 participant