GoCanvas MCP Server (read-only)
by kbates97
README.md
# GoCanvas MCP Server (read-only)
A minimal [Model Context Protocol](https://modelcontextprotocol.io) server that
exposes the **read-only** endpoints of the [GoCanvas API v3](https://www.gocanvas.com)
as MCP tools. Scope is limited to four areas: **Forms, Submissions, Reports, and
Reference Data**. No create/update/delete operations are exposed.
## Tools
### Forms
| Tool | Endpoint |
| --- | --- |
| `list_forms` | `GET /forms` |
| `get_form` | `GET /forms/{form_id}` |
| `list_form_assigned_users` | `GET /forms/{form_id}/assigned_users` |
| `list_form_shared_departments` | `GET /forms/{form_id}/shared_departments` |
### Submissions
| Tool | Endpoint |
| --- | --- |
| `list_submissions` | `GET /submissions` (requires `form_id`) |
| `get_submission` | `GET /submissions/{submission_id}` |
| `list_submission_revisions` | `GET /submissions/{submission_id}/revisions` |
| `get_submission_value` | `GET /submissions/{submission_id}/values/{value_id}` |
### Reports
| Tool | Endpoint |
| --- | --- |
| `list_form_reports` | `GET /forms/{form_id}/reports` |
| `get_form_report` | `GET /forms/{form_id}/reports/{report_id}` |
| `get_submission_default_pdf` | `GET /submissions/{submission_id}/pdf` (PDF) |
| `get_submission_report_pdf` | `GET /submissions/{submission_id}/reports/{report_id}` (PDF) |
| `get_submission_standard_pdf` | `GET /submissions/{submission_id}/standard_pdf` (PDF) |
The three PDF tools return the binary PDF **inline as base64** (`content_base64`,
`content_type`, `size_bytes`) — the server is a pure passthrough and never writes
to disk, so the tools work on read-only / ephemeral hosts such as AWS Lambda.
### Reference Data
| Tool | Endpoint |
| --- | --- |
| `list_reference_data` | `GET /reference_data` |
| `get_reference_data` | `GET /reference_data/{reference_data_id}` |
### Authentication
| Tool | Endpoint |
| --- | --- |
| `refresh_oauth_token` | `POST /oauth/token` (client-credentials) |
`refresh_oauth_token` forces a fresh bearer token to be fetched and cached. It
only applies to **server-side OAuth** (`GOCANVAS_CLIENT_ID` /
`GOCANVAS_CLIENT_SECRET`) mode; in passthrough mode the caller owns the token and
the server cannot refresh it. It is normally unnecessary — the server fetches a
token on startup and refreshes it automatically before expiry and on a `401` — but
it is exposed so the agent can rotate the token explicitly. The returned access
token is masked.
## Setup
This project uses [`uv`](https://docs.astral.sh/uv/). With `uv` installed, no
manual environment setup is required — `uv run` resolves and installs
dependencies (from `pyproject.toml`) automatically on first launch.
```bash
# optional: pre-create the environment
uv sync
```
<details>
<summary>Alternative: plain pip + venv</summary>
```bash
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
```
</details>
## Configuration
There are **two independent authentication hops**, and they use different
credentials on purpose.
### Hop 1 — caller to this server
Under an HTTP transport, every request must present a shared secret in the
`MCP_GATEWAY_HEADER` header (default `x-mcp-key`). Anything else gets `401`.
This is what keeps a public endpoint from being usable by the public.
| Variable | Description |
| --- | --- |
| `MCP_GATEWAY_KEYS` | Comma-separated list of accepted secrets. A list rather than one value so keys rotate with no downtime: add the new key, repoint the caller, drop the old. Generate with `openssl rand -base64 32`. |
| `MCP_GATEWAY_HEADER` | Header the secret is read from (default `x-mcp-key`). **Never set this to `Authorization`** — that header feeds the GoCanvas passthrough path, so reusing it would forward your gateway secret upstream to the GoCanvas API. |
| `GOCANVAS_REQUIRE_GATEWAY_KEY` | Default `true`. With no keys configured the endpoint returns `503` rather than serving openly. Set `false` only for local development bound to loopback. |
`stdio` is unaffected — the check only applies to the HTTP transports.
### Hop 2 — this server to the GoCanvas API
Resolved per request, in priority order:
| Source | Description |
| --- | --- |
| Incoming `Authorization` header | Forwarded verbatim. **Only when `GOCANVAS_ALLOW_PASSTHROUGH=true`, which is off by default** — otherwise a caller could override the server's own credentials, and any bearer they send would be relayed to a third party. |
| `GOCANVAS_CLIENT_ID` / `GOCANVAS_CLIENT_SECRET` | OAuth 2.0 client credentials. A short-lived bearer is fetched from `/oauth/token`, cached, and auto-refreshed on expiry or `401`. The recommended mode for hosted use. |
| `GOCANVAS_API_TOKEN` | Static bearer token. |
| `GOCANVAS_USERNAME` / `GOCANVAS_PASSWORD` | HTTP Basic auth (fallback). |
### Secrets Manager
For hosted deployments, keep both hops' credentials in one secret instead of in
environment variables:
| Variable | Description |
| --- | --- |
| `GOCANVAS_SECRET_ID` | Name or ARN of a Secrets Manager secret whose JSON body may hold `client_id`, `client_secret`, `scope`, `gateway_keys`. Read once per process (per Lambda container) and cached; takes priority over the equivalent environment variables. |
| `GOCANVAS_SECRET_REGION` | Defaults to `AWS_REGION`. |
A failure to read the secret is logged and falls back to the environment
variables rather than crashing the server.
### Other optional variables
| Variable | Description |
| --- | --- |
| `GOCANVAS_OAUTH_SCOPE` | Optional OAuth scope to request (server-side OAuth only). |
| `GOCANVAS_BASE_URL` | Defaults to `https://api.gocanvas.com/api/v3`. |
| `GOCANVAS_TIMEOUT` | HTTP timeout in seconds (default `30`). |
| `GOCANVAS_TRANSPORT` | `stdio` (default), `streamable-http`, or `sse`. |
| `GOCANVAS_HOST` | Bind host for HTTP transports (default `127.0.0.1`). |
| `GOCANVAS_PORT` | Bind port for HTTP transports (default `8000`). |
| `GOCANVAS_STATELESS_HTTP` | No per-session state between requests (default `true`; required for Lambda). |
| `GOCANVAS_JSON_RESPONSE` | Return JSON instead of an SSE stream (default `true`; required for Lambda). |
| `GOCANVAS_ALLOWED_HOSTS` | Comma-separated `Host` allow-list for DNS-rebinding protection. Defaults to localhost only, which returns HTTP `421` behind API Gateway / a Function URL — set your public domain or `*` when hosting publicly. |
| `GOCANVAS_ALLOWED_ORIGINS` | Comma-separated `Origin` allow-list (same semantics). |
If no usable credentials are available for a call, the tool returns a clear
error — the server itself still starts fine.
## Running
### Locally over stdio (default)
```bash
GOCANVAS_CLIENT_ID=... GOCANVAS_CLIENT_SECRET=... uv run server.py
```
### Publicly over HTTP
Run with an HTTP transport and a gateway key:
```bash
GOCANVAS_TRANSPORT=streamable-http GOCANVAS_HOST=0.0.0.0 GOCANVAS_PORT=8000 \
MCP_GATEWAY_KEYS="$(openssl rand -base64 32)" \
GOCANVAS_CLIENT_ID=... GOCANVAS_CLIENT_SECRET=... \
uv run server.py
```
The MCP endpoint is served at `/mcp`. No PDFs or other state are written to
disk, so the server runs cleanly on read-only / ephemeral hosts.
> **Hosting publicly?** Set `GOCANVAS_ALLOWED_HOSTS` to your public domain (or
> `*`). The default DNS-rebinding protection allows only localhost and returns
> HTTP `421 Misdirected Request` for any other `Host` header.
## Deploying to AWS Lambda
The module exposes an ASGI app (`asgi_app()`) and a Mangum-wrapped Lambda entry
point (`lambda_handler`), so it runs on Lambda behind a **Lambda Function URL**
or an **API Gateway HTTP API** with no long-running process. Stateless +
JSON-response mode is the default (Lambda containers are ephemeral and don't
share session state, and API Gateway can't proxy an SSE stream).
[`template.yaml`](template.yaml) is a complete [AWS SAM](https://docs.aws.amazon.com/serverless-application-model/)
stack: the function, a Function URL, a Secrets Manager secret, a log group with
retention, and a concurrency cap.
### Prerequisites
- An AWS account and the [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html),
configured (`aws configure`) with permission to create Lambda functions, IAM
roles, secrets, and log groups.
- The [SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html)
(`brew install aws-sam-cli` on macOS).
- GoCanvas API **client ID and secret** for a service account, from the GoCanvas
admin console.
### 1. Build and deploy
```bash
sam build
```
The function uses `BuildMethod: makefile`, so this runs the
`build-GoCanvasMcpFunction` target in [`Makefile`](Makefile) rather than SAM's
default Python builder. Two reasons: the default builder copies the entire
`CodeUri` directory, which here would ship the 49 MB local `.venv` and the
OpenAPI specs; and the `Makefile` pins `--platform manylinux2014_aarch64
--python-version 3.12 --only-binary=:all:` so the correct Linux/arm64 wheels are
installed even when you build from macOS. **No Docker or `--use-container`
needed.** If you change `Runtime` or `Architectures` in the template, update
those flags to match.
Then deploy:
```bash
sam deploy --guided
```
Accept the defaults, except: answer **yes** to
`GoCanvasMcpFunction Function URL may not have authorization defined, Is this okay?`
— `AuthType: NONE` is required because no MCP client can produce a SigV4
signature. The gateway-key check inside the function is the access control.
Subsequent deploys are just `sam build && sam deploy`.
Note the stack outputs — `McpEndpoint`, `SecretArn`, `GatewayHeaderName`:
```bash
aws cloudformation describe-stacks --stack-name gocanvas-mcp \
--query 'Stacks[0].Outputs' --output table
```
### 2. Populate the secret
The stack creates the secret itself but deliberately gives it **no value** — the
value is not a template-managed property, so that a later stack update can never
reassert a placeholder over your live credentials. Until you run the command
below, the endpoint returns `503` and each cold start logs a warning naming this
step.
Use `put-secret-value`, not `create-secret`: the secret already exists by this
point, so `create-secret` fails with `ResourceExistsException`. The same command
is also how you rotate later. Generate a gateway key and write both sets of
credentials in one shot:
```bash
GATEWAY_KEY="$(openssl rand -base64 32)"
echo "Gateway key (save this, you will paste it into Copilot Studio): $GATEWAY_KEY"
aws secretsmanager put-secret-value \
--secret-id gocanvas-mcp/config \
--secret-string "$(jq -n \
--arg cid "YOUR_GOCANVAS_CLIENT_ID" \
--arg csec "YOUR_GOCANVAS_CLIENT_SECRET" \
--arg key "$GATEWAY_KEY" \
'{client_id:$cid, client_secret:$csec, gateway_keys:[$key]}')"
```
Because the secret is cached per Lambda container, changes take effect on the
next cold start. To apply immediately, force new containers:
```bash
aws lambda update-function-configuration --function-name gocanvas-mcp \
--description "secret rotated $(date -u +%FT%TZ)"
```
### 3. Verify
Without the key — expect `401`:
```bash
curl -s -o /dev/null -w '%{http_code}\n' -X POST "$MCP_ENDPOINT" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}'
```
With the key — expect `200` and a `serverInfo` block:
```bash
curl -s -X POST "$MCP_ENDPOINT" \
-H "x-mcp-key: $GATEWAY_KEY" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}'
```
If you get `421 Misdirected Request`, `GOCANVAS_ALLOWED_HOSTS` does not include
the Function URL host. If you get `503 server not configured`, the secret has no
`gateway_keys` (or the function can't read it — check CloudWatch).
### 4. Tighten the host allow-list
The Function URL hostname isn't known until the stack exists, so the template
bootstraps with `AllowedHosts: "*"`. Once deployed, pin it:
```bash
sam deploy --parameter-overrides \
"AllowedHosts=abc123xyz.lambda-url.us-east-1.on.aws"
```
### Connecting Copilot Studio
Copilot Studio reaches an MCP server through a Power Platform **custom
connector**. Its OAuth path is authorization-code-on-behalf-of-user (the PKCE
flow GoCanvas doesn't play well with), and it would make each end user sign in —
neither of which is what you want here. Use API Key auth instead, which gives
the agent a standing credential:
1. **Create the connector.** In Copilot Studio, *Tools → Add a tool → New tool →
Model Context Protocol*, or import an OpenAPI definition carrying
`x-ms-agentic-protocol: mcp-streamable-1.0`. Host = your Function URL
hostname, base path = `/mcp`.
2. **Security tab.** Authentication type **API Key**; parameter label anything;
parameter name **`x-mcp-key`**; location **Header**.
3. **Create the connection** once, pasting the gateway key from step 2 above.
4. **Set the tool to use the maker/author-provided connection**, not end-user
credentials. This is what makes the agent's access standing — no per-user
sign-in prompt, and the agent works for every user of the published agent.
### What this trades away
Every Copilot Studio user now acts as **one GoCanvas service account**. GoCanvas
can no longer enforce per-user authorization, because it only ever sees that
account. The tools here are read-only, which bounds the blast radius, but scope
the service account's GoCanvas permissions to the minimum set of forms and data
the agent should ever surface. "The agent won't ask for it" is not a control.
### Hardening beyond the key
- **WAF IP allow-list.** Front the Function URL with CloudFront + AWS WAF and
allow only the published Power Platform outbound IP ranges for your region.
Treat this as defense in depth, not a primary control: the ranges are shared
across all Power Platform tenants and they change, so you have to track the
published list. The value is that a leaked key alone stops being enough.
- **Concurrency cap.** `ReservedConcurrency` (default `10`) bounds what a leaked
key can cost you and keeps it from exhausting your GoCanvas rate limit.
- **Watch the rejections.** Failed gateway checks log at `WARNING` to
`/aws/lambda/gocanvas-mcp`. A metric filter on `Rejected MCP request` plus an
alarm turns a leaked or probed endpoint into a page.
**Payload-size limit.** API Gateway / a buffered Function URL caps a response at
**6 MB**. The PDF tools return the file base64-encoded inline (~33% overhead), so
a PDF larger than ~4.5 MB can exceed that limit. Raise `MemorySize`/`Timeout` for
large forms; for consistently large PDFs, front the function with a Function URL
in **`RESPONSE_STREAM`** invoke mode or fetch the PDF out-of-band.
## MCP client configuration
Use `uv run` as the command. `--directory` points `uv` at this project so it
uses the right dependencies regardless of the client's working directory:
```json
{
"mcpServers": {
"gocanvas": {
"command": "uv",
"args": [
"run",
"--directory", "/absolute/path/to/GoCanvas",
"server.py"
],
"env": {
"GOCANVAS_CLIENT_ID": "your_client_id",
"GOCANVAS_CLIENT_SECRET": "your_client_secret"
}
}
}
}
```
If `uv` isn't on the client's `PATH`, use its absolute path (e.g.
`~/.local/bin/uv`) as the `command`.
<details>
<summary>Alternative: point at a virtualenv interpreter (pip users)</summary>
```json
{
"mcpServers": {
"gocanvas": {
"command": "/absolute/path/to/GoCanvas/.venv/bin/python",
"args": ["/absolute/path/to/GoCanvas/server.py"],
"env": {
"GOCANVAS_CLIENT_ID": "your_client_id",
"GOCANVAS_CLIENT_SECRET": "your_client_secret"
}
}
}
}
```
On Windows the interpreter is at `.venv\Scripts\python.exe`. Using a bare
`python` will fail with `ModuleNotFoundError: httpx` because the client does not
use your activated shell environment.
</details>
## Notes
- **Pagination:** list tools accept a `page` argument. Response pagination headers
(`link`, `current-page`, `page-items`, `total-count`, `total-pages`) are surfaced
under a `pagination` key in the tool result.
- **Warm Lambda containers:** `lambda_handler` rebuilds the ASGI app on each
invocation. Mangum runs the ASGI lifespan per invocation and
`StreamableHTTPSessionManager.run()` raises if entered twice, so a cached app
would make every request after a container's first one fail. Rebuilding costs
a route table per request and is free of correctness cost in stateless mode.
- **Rate limiting:** the server honors `429 Too Many Requests` responses, waiting
according to the `RateLimit-Reset` / `RateLimit-Remaining` headers (or a bounded
exponential backoff) before retrying, per GoCanvas best practices.
TDQS
A3.7/5.0
Scored across 16 tools
Disambiguation5/5
Each tool targets a distinct resource or operation: forms, submissions, reference data, PDFs, and OAuth. No overlapping purposes; descriptions clearly differentiate.
Naming Consistency5/5
All tools follow a consistent verb_noun pattern in snake_case (e.g., list_forms, get_submission). No deviations or mixed conventions.
Tool Count5/5
16 tools cover the read-only surface of forms, submissions, reports, reference data, and PDFs. The count is well-scoped for the server's purpose.
Completeness5/5
The tool set provides full read coverage for the domain: listing and getting forms, submissions, reports, reference data, plus PDF downloads. No obvious gaps remain.
Maintenance
ActivityMaintained
ResponsivenessNo issues