MCP for tool builders

Build your own MCP-aware client against Dalea.

Connect Claude Desktop is for end users who want their existing LLM to talk to Dalea. This page is different: it's for developers who want to build their own MCP-aware client — a custom agent, an internal tool, a CLI, a desktop assistant — that uses Dalea's tools.

What you get

A bearer-authenticated MCP endpoint at https://dalea.app/mcp exposing 42 tools, plus code_execution and attach_files_to_sandbox on deployments where code execution is enabled. Most domains ship as a *_read / *_write pair, so the read half stands alone for a read-only integration:

  • Documents: document_markdown_read / document_markdown_write for content, document_versions_read / document_versions_write for snapshot and restore, create for a new document or file
  • Data: data_environments_*, data_tables_*, data_columns_*, data_objects_* and data_queries_* read/write pairs; result_data_read / result_data_write for measurement batches; import_data_* and export_data for bulk movement
  • Inventory: inventory_containers_*, inventory_items_* and inventory_types query and locate items, filter by container subtree, and read lot and custody state
  • Files: download_file returns the bytes inline (UTF-8, or base64 for binary, capped at 25 MB) together with a signed egress receipt you can cite in record_external_run. There is no presigned-URL tool; find files with search_all or list them with dalea_bash
  • Search: search_all returns two lists in one call: database_hits (ranked entity matches across the workspace) and rag_hits (hybrid semantic plus lexical retrieval over the knowledge corpus, which includes this wiki)
  • Provenance: provenance_read walks origin and impact, exports evidence (trail report, W3C PROV, BioCompute, Define-XML, OpenLineage, RO-Crate) and reads the review queue; record_external_run files a computation you ran elsewhere; related_entities and entity_versions_* cover lineage neighbours and version history
  • Marketplace: marketplace_packages, read-only package browsing
  • Workspace shell: dalea_bash runs one shell command over the workspace as a virtual filesystem: ls, cat, grep, find, query, stats, plus mkdir / mv / rm
  • Skills: list_skills returns the skills activated for the caller and load_skill(name) fetches one's markdown body, so the client can prime itself with domain terminology before answering

The MCP surface writes as well as reads. Every write tool declares an MCP annotation, and three are flagged destructive: document_markdown_write (its delete action removes blocks), data_environments_write (a schema commit can resolve conflicts by archiving objects) and dalea_bash (rm -r archives a folder, document or project). result_data_write retires the batch named by supersedes_batch_id, and addons_write has a delete_script action. Two things stay out of MCP: permanent file deletion (the shell refuses rm on a stored file, which needs a signed delete in the UI) and signing, since no tool can apply an e-signature or clear a review that requires one. Scope a client to the workspace role it actually needs.

Set up

Register an OAuth client

  1. Settings → Workspaces → your workspace → Applications → New Application

    Open the workspace the client should access, then its Applications tab. Give the application a name ("internal lab agent v1"), pick web or native, and add a redirect URI for the OAuth callback.

  2. Pick the role the client will act as

    This is what actually bounds the client: every MCP call is authorised against the workspace role attached to the application, exactly as if a member had made it. Viewer or Commenter for read-only patterns; Editor or Data Engineer if your agent needs to create documents or records.

  3. Pick scopes

    For an MCP client, you'll typically request:

    • openid profile email: identity
    • offline_access: refresh tokens
    • mcp:read: read documents and data
    • mcp:write: create and modify documents and data
    • mcp:tools: MCP tool access

    Code execution has its own scope, mcp:code-exec, so a client can hold read and write data tools without ever gaining a sandbox.

  4. Save the client_id and client_secret

    Treat the secret like a server-side credential.

Implement the OAuth flow

Standard OAuth 2.1 PKCE for native and CLI clients, server-side flow for web. The exchange:

your client ──/authorize?... ─────────────────────► dalea.app
              ◄── 302 with ?code=... ───────────────
              ──/token { code, code_verifier, ... }─►
              ◄── { access_token, refresh_token } ──

Save the refresh token; use the access token until it returns 401, then refresh.

Make a tool call

/mcp speaks the MCP streamable HTTP transport, so a call is not one bare POST. The client sends initialize first, sends Accept: application/json, text/event-stream, carries the Mcp-Session-Id header the server hands back on every later request, and reads responses as SSE frames. Let an MCP SDK do that for you:

# pip install mcp
import asyncio, json
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client

MCP_URL = "https://dalea.app/mcp"

async def call(session, tool, arguments):
    """Call a tool and parse Dalea's response envelope out of the result."""
    result = await session.call_tool(tool, arguments)
    return json.loads(result.content[0].text)

async def search(token: str):
    headers = {"Authorization": f"Bearer {token}"}
    async with streamablehttp_client(MCP_URL, headers=headers) as (read, write, _):
        async with ClientSession(read, write) as session:
            await session.initialize()
            return await call(session, "search_all", {
                "query": "DLA-7",
                "source_types": ["document"],
            })

envelope = asyncio.run(search(token))
for hit in envelope["data"]["database_hits"]:
    print(hit["name"], hit["url"])
// npm i @modelcontextprotocol/sdk
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";

const client = new Client({ name: "internal-lab-agent", version: "1.0.0" });
await client.connect(
  new StreamableHTTPClientTransport(new URL("https://dalea.app/mcp"), {
    requestInit: { headers: { Authorization: `Bearer ${token}` } },
  }),
);

const result = await client.callTool({
  name: "search_all",
  arguments: { query: "DLA-7", source_types: ["document"] },
});

Every tool answers with the same JSON envelope inside the tool result. On success it is {ok: true, tool, action, data, meta?}; search_all puts its two result lists at data.database_hits and data.rag_hits. An in-tool failure comes back as a normal (non-error) result carrying {ok: false, error: {code, what, why, fix, retryable}}, where code is one of a fixed set such as VALIDATION_ERROR, NOT_FOUND or RATE_LIMITED. Branch on ok, and use error.fix rather than guessing. Failures raised before the tool runs, such as a missing scope or a rejected token, surface as protocol-level MCP errors instead.

Discovering available tools

Send tools/list (client.listTools() in the SDKs). The response enumerates every tool with its name, description, input schema and annotations. Read the annotations: readOnlyHint marks a safe read, destructiveHint marks a tool whose write can remove or overwrite data. Cache the catalogue for a few minutes; it is stable but can change between platform releases. get_tool_examples returns worked argument examples for a named tool when a schema alone is not enough.

Pagination, filtering, and rate limits

  • Read tools that return lists accept limit and an opaque cursor. Pass data.page.nextCursor back verbatim with every other argument unchanged; a null nextCursor is the only end-of-data signal. Never build or edit a cursor.
  • Filters are explicit arguments, not query strings.
  • Rate limiting is per user, with a per-minute and a per-day budget. A call over budget is refused before the tool runs, as an MCP error naming the limit and a retry-after in seconds; a rate limit hit further downstream comes back in the envelope as RATE_LIMITED with retryable: true. Back off either way.
  • Every call is audited: the server writes an mcp.tool_invoked record (or mcp.tool_denied when a pre-tool check refuses) stamped with your client's name and version, so a workspace admin can see exactly what your agent did.

A small worked example: a daily PK summary

Goal: every morning, post a Slack message summarising overnight PK results.

import os, requests

async def daily_summary(session):
    # 1. Find the saved query by name (`call` is the helper defined above).
    listed = await call(session, "data_queries_read", {
        "action": "list_saved",
        "query_search": "DLA-7 PK timecourse",
    })
    qid = listed["data"]["queries"][0]["id"]

    # 2. Run it against the latest data.
    run = await call(session, "data_queries_read", {
        "action": "execute_saved",
        "query_id": qid,
    })

    # 3. Format and post to Slack.
    summary = format_pk_table(run["data"]["rows"])  # your own helper
    requests.post(os.environ["SLACK_WEBHOOK"], json={"text": summary})

Schedule this with cron, GitHub Actions, or your scheduler of choice. No human in the loop is needed here: data_queries_read is a read tool, and the OAuth client's workspace role is what stops it from reaching anything else.

For a whole table rather than a query result, export_data(action="export_csv") writes a CSV into workspace storage and returns its fileId, which download_file then streams back inline. That is cheaper than paging a large table through query reads.

Grounding answers with search_all and load_skill

Two tools deserve a callout because they make agents noticeably better without any workspace-specific code.

search_all(query, source_types?, top_k?, ...) returns two ranked lists in one call. database_hits are exact entity matches (documents, files, data objects, tables, environments, saved queries, projects, folders, inventory) that let you resolve a name to a UUID. rag_hits are hybrid semantic plus lexical chunks from the knowledge corpus: this wiki, plus the workspace's own document content, schemas, inventory and templates. Each hit carries a url the model can cite. It is read-only, so it is a safe first step for any "how do I…" or "what does X mean here?" question. The in-app chat grounds its own answers on the same rag_hits leg. Narrow it with source_types, for example ['wiki'] for product documentation only.

load_skill(name) fetches the markdown body of a named skill, a short primer for a specific domain, so the agent can adopt the right conventions before it starts work. Skills are a mix of built-ins and ones the workspace has authored. Discover them with list_skills, which returns the skills currently activated for the caller; they do not appear in tools/list, which returns the fixed tool catalogue. Cache skill bodies aggressively, they change rarely.

Both are read-only and both scope to the caller's workspace.

What MCP isn't

  • It isn't a webhook. MCP is request/response. There are no event-driven push notifications, so integrators poll on a schedule.
  • It isn't a way around review and signing. No tool can apply an e-signature, clear a run that a review policy has flagged, or hard-delete a stored file. Those stay with a human in the Dalea UI.
  • It isn't a bulk transport by itself. For a large table, call export_data and then download_file rather than paging query results, and keep in mind that inline file reads are capped at 25 MB.

Tips

Build with the SDK

The official MCP SDKs handle the JSON-RPC envelope, retries and reconnection. Save yourself the boilerplate and use them for any non-trivial client.

Scope down aggressively

An MCP client doesn't need the same role as the user who registered it. A read-only Slack notifier should be Viewer; an analysis agent that ingests data into a customer-built reporting tool should be Commenter. Match the principle of least privilege.

What's next