Skip to main content
Glama
Nettskjema MCP Server

Nettskjema MCP Server

by orjahren

Nettskjema MCP Server

Model Context Protocol (MCP) server for the Nettskjema API v3. Provides tools to interact with Nettskjema forms, submissions, and data export through the MCP protocol.

Features

  • OAuth 2.1 Authentication: Secure client credentials flow with automatic token caching and refresh

  • Form Management: Create, list, retrieve, update, and delete forms

  • Form Elements: Get, update, and replace form elements (questions)

  • Submissions: Submit, retrieve, and manage form responses

  • Data Export: Export form responses as CSV, Excel, and SPSS

  • Attachments: Upload and retrieve submission attachments with transcription support

  • Invitations: Send reminders to pending invitations (creation handled in frontend)

  • User Information: Get authenticated user details

  • Type-Safe: Full type hints and validated against the OpenAPI spec

Related MCP server: formslist-mcp

Installation

Clone this repository and install with pip or uv:

git clone git@github.com:orjahren/nettskjema-mcp.git
cd nettskjema-mcp
pip install -e .

Or with uv:

uv pip install -e .

Configuration

1. Create API Client Credentials

  1. Log into https://authorization.nettskjema.no/

  2. Click "Register client"

  3. Provide a client name and email

  4. Save the clientSecret immediately - it's shown only once

  5. Note your clientId and clientSecret

2. Configure Environment Variables

Create a .env file:

cp .env.example .env

Add your credentials:

NETTSKJEMA_CLIENT_ID=your_client_id_here
NETTSKJEMA_CLIENT_SECRET=your_client_secret_here

Security: Never commit your clientSecret. Store it in environment variables or a secure vault.

Token Management: OAuth access tokens are valid for 24 hours and are automatically cached and refreshed.

3. Grant Form Access

Your API client username is: <clientId>@apiclient

For each form you want to access:

  • Open the form in Nettskjema

  • Go to: Settings → Permissions → Editing permissions

  • Add your client username

Usage

Running the Server

After installation:

nettskjema-mcp

Or as a Python module:

python -m nettskjema_mcp

MCP Client Configuration

Add to your MCP client configuration (e.g., Claude Desktop):

{
  "mcpServers": {
    "nettskjema": {
      "command": "nettskjema-mcp",
      "env": {
        "NETTSKJEMA_CLIENT_ID": "your_client_id_here",
        "NETTSKJEMA_CLIENT_SECRET": "your_client_secret_here"
      }
    }
  }
}

Available Tools

Authentication & User

Tool

Description

check_auth_status

Check OAuth authentication status and token info

get_user_info

Get authenticated user information

Forms

Tool

Description

list_my_forms

List all your forms

create_form

Create a new form (requires title and language)

delete_forms

Delete forms by ID (with trash retention)

get_form_info

Get metadata for a form

get_form_definition

Get complete form definition

get_form_settings

Get form configuration

get_form_settings_validation

Validate form settings without saving

update_form_settings

Update form configuration

lookup_form_by_title

Find form ID by short title

activate_educloud

Activate EduCloud integration

activate_tsd

Activate TSD (Tjenester for Sensitive Data)

list_forms_collecting_personal_data

List forms that collect personal data

Form Elements

Tool

Description

get_form_elements

Get all elements in a form

update_form_element

Update a single element by ID

replace_form_elements

Replace entire element list (keep-list semantics)

Note: Element tools use read-modify-write. See docs/workflows.md for details.

Submissions

Tool

Description

submit_form

Submit a response to a form

submit_form_for_invitation

Submit response using an invitation ID

get_submission

Get a submission with answers

get_submission_metadata

Get metadata for all submissions

get_postponed_submission_metadata

Get metadata for postponed submissions

get_submission_pdf

Get PDF rendering of a submission

list_my_submissions

List your submissions

delete_submissions

Delete specific submissions by ID

delete_all_submissions

Delete all submissions from a form

Answers & Attachments

Tool

Description

get_form_answers

Get all answers for a form

get_answer_attachment

Download an attachment by ID

upload_submission_attachment

Upload file attachment to a submission

get_attachment_transcription

Get transcription of audio/video attachment

retranscribe_attachment

Request new transcription of attachment

Data Export

Tool

Description

export_form_csv

Export responses as CSV (semicolon delimiter)

export_form_excel

Export responses as Excel file

get_spss_syntax

Generate SPSS syntax file

Codebooks & Images

Tool

Description

upload_codebook

Upload a codebook for a form

upload_image

Upload an image to Vortex

get_image

Retrieve an image by ID

Invitations

Tool

Description

remind_all_pending_invitations

Send reminders to all pending invitations

Invitation Creation Not Supported: The v2 invitation API is private and not available through this MCP server. To create invitations, use the frontend at https://nettskjema.no/user/form/{formId}/collect-responses/invited/create. Configure the form for invitations via MCP using update_form_settings with respondentGroup: "INVITED", then create invitations in the web UI. See docs/workflows.md for the complete workflow.

Documentation

Testing

Run the test suite:

pytest

Run with coverage:

pytest --cov=nettskjema_mcp --cov-report=html

See CONTRIBUTING.md for development setup and testing guidelines.

API Documentation

Full Nettskjema API documentation: https://nettskjema.no/api/v3/v3/api-docs

License

Copyright (C) 2026 Oliver Ruste Jahren

This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.

This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.

You should have received a copy of the GNU General Public License along with this program. If not, see https://www.gnu.org/licenses/.

Available Tools

38 tools
activate_educloudA

Activate Educloud for a form.

The token parameter is a credential that authorizes activating Educloud for the named project. It must be kept as secure as the OAuth2 access token and is never logged or included in error messages.

The API returns 200 with no response body on success.

Inputs: token (required), form_id (required), project_name (required). Returns: result

activate_tsdB

Activate TSD (Services for Sensitive Data) for a form.

The token parameter is a credential that authorizes activating TSD for the named project. It must be kept as secure as the OAuth2 access token and is never logged or included in error messages.

The API returns 200 with no response body on success.

Inputs: token (required), form_id (required), project_name (required). Returns: result

check_auth_statusA
Read-only

Report which Nettskjema credentials and URLs this server is configured with.

Makes no request, so it still answers when authentication is the thing that is broken. The client secret is never included: only whether it is set.

Returns: A JSON object describing the current configuration, the names of any missing environment variables, and whether the server is ready to call the API.

Inputs: no named parameters. Returns: result

create_formA

Create a new form with the given settings and optional elements.

The six required settings are taken as named parameters so a model cannot omit them. Additional settings can be provided via extra_settings. If a required setting appears in extra_settings, the named parameter takes precedence.

Inputs: title (required), form_type (required), language_code (required), respondent_group (required), +4 more. Returns: result

delete_all_submissionsA
Destructive

Delete EVERY response the form has ever collected. This CANNOT be undone.

There is no id list and no partial form of this call: the whole dataset goes, for every respondent, and the API offers no undo and no trash can for submissions. Confirm with the user before calling it. To remove particular responses, use delete_submissions, which takes submission ids and leaves the rest of the form's data in place.

The spec's plural operation accepts an optional array body, but documents the operation as deleting all submissions and never says the ids narrow it. This tool therefore sends no body and offers no id parameter, rather than a parameter that looks like it limits the damage and may not.

Inputs: form_id (required). Returns: result

delete_formsA
Destructive

Delete one or more forms by their IDs.

Deleted forms are moved to the trash and can be restored for a limited time. This operation is destructive and cannot be undone after the trash retention period expires.

Inputs: form_ids (required). Returns: result

delete_submissionsA
Destructive

Delete ONLY the listed responses to a form. Other responses are left alone.

This is the scoped delete, and the one to use whenever specific responses are meant: it removes exactly the submissions whose ids are given, and nothing else. Do not confuse it with delete_all_submissions, which deletes every response the form has and takes no id list. Deleted responses are gone; the API offers no undo for them.

Get submission ids from get_submission_metadata or list_my_submissions. An empty list is refused rather than sent, because what the API would do with an empty body here is not specified and the cost of guessing wrong is a form's data.

Inputs: form_id (required), submission_ids (required). Returns: result

export_form_csvA

Export a form's submission data as CSV.

The spec's summary describes the response as a CSV text file with semicolon (;) as the delimiter. When output_path is provided, the file is written to disk (mode 0600 for a new file) and the result reports the path, byte count and media type. When output_path is omitted, the text is returned as JSON with a size signal and truncation flag. The result is capped at max_chars (default 1,000,000) to bound context size: check the truncated field to see if the full export was cut.

Inputs: form_id (required), max_chars, output_path, optimize_for_large_data. Returns: result

export_form_excelA

Export a form's submission data to a file, written to disk.

The spec documents this route's response as an Excel file. The file is streamed to disk and never inlined: a multi-megabyte spreadsheet as base64 is useless to a model. The returned path is absolute and tilde-expanded. An existing file at the output path is silently overwritten, and its mode is preserved. A new file (whether from output_path or a temp file) is created with mode 0600 (readable only by the current user).

When output_path is omitted, a temporary file is created in the system temp directory with a suffix derived from the response's media type. The temporary file persists after this call returns and is left on disk for the caller to use or delete.

Inputs: form_id (required), output_path, optimize_for_large_data. Returns: result

get_answer_attachmentA

Download an answer attachment to a file, written to disk.

The attachment is streamed to disk and never inlined: a multi-megabyte audio recording as base64 is useless to a model. The returned path is absolute and tilde-expanded. An existing file at the output path is silently overwritten, and its mode is preserved. A new file (whether from output_path or a temp file) is created with mode 0600 (readable only by the current user).

When output_path is omitted, a temporary file is created in the system temp directory with a suffix derived from the response's media type. The temporary file persists after this call returns and is left on disk for the caller to use or delete.

Inputs: answer_attachment_id (required), output_path. Returns: result

get_attachment_transcriptionA
Read-only

Get the transcription of an attachment in JSON, plain text, or WebVTT format.

The attachment must have been uploaded with transcription options for this to return meaningful results. A file uploaded without transcription, or a file that is not audio or video, has no transcription to retrieve.

For txt and vtt formats, the result is capped at max_chars (default 1,000,000) to bound context size. Check the truncated field to see if the full text was cut. The caller may raise this limit arbitrarily by passing a larger value.

Inputs: answer_attachment_id (required), format, max_chars. Returns: result

get_form_answersA
Read-only

Stream all answers for a form, as newline-delimited JSON.

This exports every answer on the form, one JSON object per line (NDJSON), so a large form's answers are never held whole in memory. Filter by element_ids to export only specific questions. The result is capped at max_rows (default 1000) to bound context size: check the returned truncated field to see if more data exists. When truncated is true, the reason field distinguishes a hit row cap ("rowCap") from a connection that closed mid-line ("incompleteFinalLine").

Inputs: form_id (required), max_rows, element_ids, optimize_for_large_data, +1 more. Returns: result

get_form_definitionC
Read-only

Get the form's structure and creation history: the elements array and authorship metadata.

Inputs: form_id (required). Returns: result

get_form_elementsA
Read-only

Get the form's elements: its questions, headings, images and page breaks.

Use this before any element edit. The only write endpoint replaces the whole list, so the array returned here is the starting point for a modified list.

Inputs: form_id (required). Returns: result

get_form_infoB
Read-only

Get operational metadata about a form: submission counts, open/closed status, and edit permissions.

Inputs: form_id (required). Returns: result

get_form_settingsB
Read-only

Get the form's configuration: all 51 settings controlling form behavior and permissions.

Inputs: form_id (required), is_for_create_form. Returns: result

get_form_settings_validationC
Read-only

Get validation errors or constraints for the form's settings.

Inputs: form_id (required). Returns: result

get_imageA

Download an image to a file, written to disk.

The image is streamed to disk and never inlined. The returned path is absolute and tilde-expanded. An existing file at the output path is silently overwritten, and its mode is preserved. A new file (whether from output_path or a temp file) is created with mode 0600 (readable only by the current user).

When output_path is omitted, a temporary file is created in the system temp directory with a suffix derived from the response's media type. The temporary file persists after this call returns and is left on disk for the caller to use or delete.

The spec declares the response as / (any media type), so the actual content type is reported as received.

Inputs: image_id (required), output_path. Returns: result

get_postponed_submission_metadataA
Read-only

Stream postponed submission metadata for a form, as newline-delimited JSON.

This endpoint returns the same metadata structure as get_submission_metadata, but only for postponed submissions. The result is streamed as NDJSON and capped at max_rows to bound context size.

Distinct from get_submission_metadata: this endpoint returns postponed submission metadata only, while that one returns all submission metadata for the form.

Inputs: form_id (required), max_rows, submission_ids_greater_than. Returns: result

get_spss_syntaxA
Read-only

Get SPSS syntax for a form's submission data.

The spec documents this route's response as an SPSS syntax file. The syntax is returned as JSON with a size signal and truncation flag. The result is capped at max_chars (default 1,000,000) to bound context size: check the truncated field to see if the full syntax was cut.

Inputs: form_id (required), max_chars. Returns: result

get_submissionB
Read-only

Get a single submission with its answers and metadata.

Inputs: submission_id (required). Returns: result

get_submission_metadataA
Read-only

Stream all submission metadata for a form, as newline-delimited JSON.

Returns metadata without answers: use this for bulk export of submission-level fields or to find which submissions exist before fetching their answers. This streams NDJSON (one JSON object per line), so a large form's metadata is never held whole in memory. The result is capped at max_rows to bound context size: check the returned truncated field to see if more data exists.

Distinct from get_postponed_submission_metadata, which returns postponed submission metadata only. The relationship between the two result sets (whether this endpoint includes or excludes postponed submissions) is not specified by the API documentation.

Inputs: form_id (required), max_rows, optimize_for_large_data, submission_ids_greater_than. Returns: result

get_submission_pdfA

Download a submission as a PDF file.

The PDF is written to disk, not returned inline: a multi-megabyte PDF as base64 is not useful to a model. The returned path is absolute and tilde-expanded. An existing file at the output path is silently overwritten, and its mode is preserved. A new file (whether from output_path or a temp file) is created with mode 0600 (readable only by the current user).

When output_path is omitted, a temporary file is created in the system temp directory with a .pdf suffix. The temporary file persists after this call returns and is left on disk for the caller to use or delete.

Inputs: submission_id (required), output_path. Returns: result

get_user_infoB
Read-only

Get the Nettskjema user record for the authenticated API client.

Returns: The user record as JSON.

Inputs: no named parameters. Returns: result

list_forms_collecting_personal_dataC
Read-only

List forms that collect personal data, with optional filters.

Inputs: anonymous, inactive_personal_data_suspected. Returns: result

list_my_formsB
Read-only

List all forms owned by the authenticated API client.

Returns: An array of form summary objects as JSON. Each object includes formId, title, open (boolean), modifiedDate, lastSubmissionDate, numberOfDeliveredSubmissions, deliveryDestination, and myFormsFormListingGroup.

Inputs: no named parameters. Returns: result

list_my_submissionsB
Read-only

List all submissions made by the authenticated API client.

Returns: An array of submission summary objects as JSON. Each object includes submissionId, form (with title), createdDate, modifiedDate, and delivered (boolean).

Inputs: no named parameters. Returns: result

lookup_form_by_titleB
Read-only

Look up a form by its short title (the URL-safe identifier).

Inputs: title_short (required). Returns: result

remind_all_pending_invitationsA
Destructive

Send reminder emails to every pending invitation on a form.

This operation is all-or-nothing: every sent invitation on the form receives a reminder. There is no endpoint to remind a subset of invitations. Whether it also reaches invitations created through the newer batch interface is not documented anywhere; they may share the same storage, but that is an inference and not something the API states.

Creating, viewing and deleting invitations are not available through this MCP server. Those operations are public Nettskjema features — supported, documented, production-ready — but are managed through the web frontend instead. Use the form management interface to handle invitations: https://nettskjema.no/user/form/{formId}/collect-responses/invited/create and https://nettskjema.no/user/form/{formId}/collect-responses/invited/overview

Inputs: form_id (required). Returns: result

replace_form_elementsA
Destructive

Replace the form's ENTIRE element list. Every element you leave out is DELETED.

This is not an "add an element" tool. Whatever you send becomes the form's complete content: any question, heading, image or page break that is not in elements is removed from the form. To add or change anything, call get_form_elements first, modify the array it returns, and send all of it back — or call update_form_element, which does that read-modify-write for one element. Sending a one-element list to a ten-element form deletes nine elements.

Nothing locks the form in between: an edit made by someone else after your get_form_elements call is overwritten by this one without warning. The endpoint offers no version field, ETag or conditional header to detect that.

Each element is checked against the vendored ElementDTO schema before the request is sent: elementType is required on every element and must be one of the known types, and every field the schema declares is checked for type and range. A field the schema does not declare is passed through to the API rather than rejected here, so an element read back from a newer API than the vendored snapshot can still be written back unchanged.

Inputs: form_id (required), elements (required). Returns: result

retranscribe_attachmentA
Destructive

Retranscribe an existing attachment with new transcription options.

The API overwrites the existing transcription. The attachment must be audio or video; retranscribing a non-media file or a file that was never transcribed has undefined effect.

The six supported models are: model_auto, nb_whisper_large_semantic, nb_whisper_large_verbatim, openai_whisper_large_v3, openai_whisper_large_v2, and KBLab-large. The language parameter accepts 100+ language codes; passing an unknown code raises BadRequestError naming the allowed values. The diarize flag requests speaker identification, and translate_to_english requests translation to English after transcription.

Inputs: answer_attachment_id (required), model, diarize, language, +1 more. Returns: result

submit_formA

Deliver a response to a form, as the authenticated API client.

This submits a real answer to a real form: it adds a row to the form's collected data, and the form's owner sees it alongside human responses. There is no draft or preview mode here. Use submit_form_for_invitation instead when answering on behalf of a specific invitation.

Each answer is checked against the vendored ClientAnswerDTO schema before the request is sent, so a bad enum value or a mistyped field fails without reaching the form. A field the snapshot does not declare is passed through to the API, which has the final say.

Inputs: answers (required), form_id (required), metadata. Returns: result

submit_form_for_invitationA

Deliver a response to the form a given invitation was issued for.

Same delivery as submit_form, addressed by invitation rather than by form: the response is recorded against that invitation, so the form's owner can see which invited respondent answered. The invitation is identified by its own numeric id, not by the form's. This submits a real answer to a real form; there is no draft or preview mode.

Each answer is checked against the vendored ClientAnswerDTO schema before the request is sent. A field the snapshot does not declare is passed through to the API, which has the final say.

Inputs: answers (required), invitation_id (required), metadata. Returns: result

update_form_elementA
Destructive

Change one element, by rewriting the form's ENTIRE element list.

The API has no single-element update, so this tool reads every element, applies changes to the one whose elementId matches, and writes all of them back through the same whole-list PUT that replace_form_elements uses. The other elements are sent back exactly as they were read — but they are sent, so an edit someone else makes between the read and the write is silently overwritten. The operation is NOT atomic, and the endpoint offers no version field, ETag or conditional header that would detect the conflict.

changes is merged shallowly, one level deep: each key replaces that key's value on the element outright. Nested lists are NOT merged item by item — passing answerOptions replaces the element's whole answer-option list, so to edit one option you must send all of them, each keeping its answerOptionId, in the order you want. The same goes for subElements and validationScripts. Fetch the element with get_form_elements first if you do not already have them.

If no element in the form has the given element_id, nothing is written: a PUT of the unchanged list would report success while changing nothing. For the same reason changes may not be empty, and may not carry elementId — changing an element's own id does not move the edit to another element, it asks the API to replace this one with a different element.

Inputs: changes (required), form_id (required), element_id (required). Returns: result

update_form_settingsA

Update one or more settings on an existing form.

This endpoint accepts partial updates: only the settings included in the settings dict will be updated. A 207 Multi-Status response means some settings were applied and others were rejected; this is reported as an error listing the failed settings.

The settings dict is validated as a partial FormSettingsDTO before the request is sent. Required-field constraints are relaxed for partial updates, but unknown keys are rejected locally to catch typos and flag fields that are newer than the vendored spec snapshot.

Inputs: form_id (required), settings (required), force_cleanup. Returns: result

upload_codebookA
Destructive

Upload a codebook mapping form elements to external identifiers.

The spec does not document whether omitted entries are preserved or deleted. Send the complete mapping you want the form to have: include every element, sub-element and answer option whose external id you want to keep or set. Omitting an entry has undefined effect and may delete that mapping.

Each map is validated against the CodebookDTO schema before the request is sent. All three maps are required by the schema: send an empty dict for any map you do not need to populate.

Inputs: form_id (required), element_ids_and_external_element_ids_map (required), sub_element_ids_and_external_element_ids_map (re… Returns: result

upload_imageB

Upload an image to the API, returning a response with string keys and values.

The entire file is read into memory, base64-encoded, and sent as a JSON string in the request body, per the published OpenAPI schema ({"type": "string", "format": "byte"} under application/json). The filename is derived from the file itself and sent as a query parameter.

The body format is most likely a Spring Boot serialisation artefact where a byte[] parameter was rendered as format: byte. The actual server may expect raw bytes; whether it accepts the JSON string can only be settled end-to-end.

Inputs: file_path (required). Returns: result

upload_submission_attachmentA
Destructive

Upload an attachment to a submission question, optionally requesting transcription.

The entire file is read into memory before uploading. The filename and fileSize are derived from the file itself rather than accepted as parameters, so they cannot drift out of sync. An audio recording awaiting transcription can be tens of megabytes; the whole file is held in RAM during the upload.

The attachment token comes from the attachmentToken field in the response body of POST /form/{formId}/submission. It authorizes writing to someone else's submission and must be kept as secure as the access token itself.

The spec does not document what happens when the named questionId already has an attachment. An upload may silently replace a respondent's file.

Transcription options apply only when the uploaded file is audio or video. The six supported models are: model_auto, nb_whisper_large_semantic, nb_whisper_large_verbatim, openai_whisper_large_v3, openai_whisper_large_v2, and KBLab-large. The language parameter accepts 100+ language codes; passing an unknown code raises BadRequestError naming the allowed values. The diarize flag requests speaker identification, and translate_to_english requests translation to English after transcription.

Inputs: file_path (required), question_id (required), submission_id (required), attachment_token (required), +4 more. Returns: result

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

Loading tool schema changelog…

    TDQS

    A3.7/5.0

    Scored across 38 tools

    Disambiguation5/5

    Despite 38 tools, purposes are sharply distinct and descriptions actively call out near-neighbors (e.g. delete_submissions vs delete_all_submissions, get_submission_metadata vs get_postponed_submission_metadata, replace_form_elements vs update_form_element). The cluster of get_form_info/settings/settings_validation/definition tools targets distinct facets (metadata, config, validation, structure) and is well differentiated in prose.

    Naming Consistency5/5

    Near-perfect snake_case verb_noun convention throughout: get_*, list_*, create_*, update_*, delete_*, upload_*, export_*, submit_*, activate_*. Pluralization and scoping (delete_forms, delete_submissions, delete_all_submissions) is used meaningfully and consistently.

    Tool Count3/5

    38 tools is heavy and exceeds the comfortable 15-tool band, spanning forms, elements, settings, submissions, attachments, transcription, exports, invitations, users, auth, and codebook. The domain is genuinely broad and most tools map to distinct endpoints, but the surface is large enough that consolidation (e.g. the several get_form_* readers) would help.

    Completeness4/5

    Core lifecycle is well covered: form create/read/delete, element read/replace/update, settings read/update/validate, submission create/get/list/bulk-export/delete, attachment upload/download/transcribe, and multiple export formats. Gaps remain in invitation management (only remind_all_pending_invitations; create/view/delete are acknowledged as unavailable) and codebook is upload-only with no update/delete or form rename.

    Related MCP Connectors

    Related MCP Servers